Fix a Broken Django Virtualenv After an OS Upgrade
Fix a Django virtualenv that breaks after upgrading Ubuntu or Debian or changing the Python version: errors like No module named encodings and bad interpreter, recreating the venv safely, and avoiding downtime during the switch.
Problem statement
After do-release-upgrade from Ubuntu 22.04 to 24.04, or a Debian major upgrade, Gunicorn will not start:
/srv/app/.venv/bin/gunicorn: bad interpreter: /srv/app/.venv/bin/python3.10: no such file or directory
or Python itself fails to start inside the venv:
Fatal Python error: init_fs_encoding: failed to get the Python codec of the filesystem encoding
ModuleNotFoundError: No module named 'encodings'
A virtualenv is not a self-contained copy of Python. It points at the base interpreter it was created from (/usr/bin/python3.10), and compiled packages inside it are built for that exact version. When the OS upgrade replaces Python 3.10 with 3.12, the venv's interpreter link and every compiled extension are left pointing at something that no longer exists.
- Do not try to repair the venv; recreate it with the new Python.
- Build the new venv beside the old one:
python3 -m venv /srv/app/.venv-new. - Install the locked requirements into it and run
manage.py check. - Swap directories and restart Gunicorn and Celery.
- Check that all dependencies support the new Python version before the OS upgrade.
Step-by-step solution
1. Confirm the cause
ls -l /srv/app/.venv/bin/python*
cat /srv/app/.venv/pyvenv.cfg
python3 --version
pyvenv.cfg records home = /usr/bin and version = 3.10.12. If the base version no longer exists on the system, the venv cannot work.
2. Check compatibility first
Before recreating, make sure your pinned packages support the new Python. Old pins of psycopg2, lxml, Pillow, numpy, or Django itself may not have wheels or support for 3.12:
python3.12 -m venv /tmp/compat && /tmp/compat/bin/pip install -r requirements.txt --dry-run
Django 4.2.8+ and 5.0+ support Python 3.12; Django 5.1+ supports 3.13. Upgrade pins in your repository and test in CI before touching production.
3. Build a new venv beside the old one
sudo apt install -y python3-venv python3-dev build-essential
cd /srv/app
python3 -m venv .venv-new
.venv-new/bin/pip install --upgrade pip
.venv-new/bin/pip install -r requirements.txt
.venv-new/bin/python manage.py check --deploy
Building alongside keeps the old directory available for comparison, although after the OS upgrade the old venv is already broken, so the service is down until the swap.
4. Swap and restart
mv .venv .venv-old
mv .venv-new .venv
sudo systemctl restart gunicorn celery-worker celery-beat
Scripts inside a venv contain absolute shebang paths (#!/srv/app/.venv-new/bin/python). Because those paths would point at .venv-new after the rename, recreate the venv directly at its final path when you can, or reinstall console scripts with pip install --force-reinstall gunicorn celery after renaming. The safest pattern is to create the venv at a versioned path and point a symlink at it:
python3 -m venv /srv/app/venvs/py312-2026-10-02
ln -sfn /srv/app/venvs/py312-2026-10-02 /srv/app/.venv
Then services use /srv/app/.venv/bin/... and switching is one symlink change.
5. Prefer a pinned Python next time
To decouple the app from OS upgrades, install the Python version yourself with uv python install 3.12 or the deadsnakes PPA, and build the venv from that. OS upgrades then do not change the app's interpreter; you upgrade Python deliberately, on your schedule.
Verification check:
/srv/app/.venv/bin/python -c "import sys, django; print(sys.version, django.get_version())"
systemctl is-active gunicorn celery-worker
curl -sI https://example.com | head -1
Explanation
python -m venv creates a directory with a symlink (or copy of a small launcher) to the base interpreter, a pyvenv.cfg pointing at the base installation, and its own site-packages. The standard library is not copied; it is loaded from the base installation. Compiled extension modules are built against one Python ABI, such as cpython-310. An OS upgrade that removes the old interpreter therefore breaks the venv at its foundations, and no amount of reinstalling individual packages fixes that.
Edge cases / notes
- Unattended upgrades apply patch releases (3.12.3 to 3.12.4) without breaking venvs; only minor version changes (3.10 to 3.12) do.
--copiescopies the interpreter binary into the venv but still depends on the base standard library, so it does not survive removal either.- Docker images pin the Python version in the base image tag, which avoids this entirely; upgrade by changing the tag.
- Cron and timers that reference the old path need updating if you change the venv location.
Next steps
- Install correctly on new releases with Fix pip externally-managed-environment on a Django Server.
- Plan version jumps with the Django Upgrade Checklist.
FAQ
Can I fix a broken venv without recreating it?
Not reliably. Recreating from your requirements or lock file is faster and safer.
Why does No module named encodings appear?
The venv cannot find the standard library of the Python it was built with, because that Python was removed.
How do I avoid this on future OS upgrades?
Install a Python version you control (uv or deadsnakes) or deploy in containers with a pinned Python tag.
Do patch updates break virtualenvs?
No. Only changes to the minor version, such as 3.10 to 3.12, break them.