Upgrading Django 4.2 to 5.2 LTS: what breaks and how to fix it
By Ryan Murdoc, founder of Unstale · Published 13 October 2026 · Last checked October 13, 2026 · 5 min read
Django 4.2 LTS reached the end of security support in April 2026.
Django 4.2 LTS no longer gets security fixes: its last release came out in April 2026. The next long-term support release, Django 5.2 LTS, is supported until April 2028, so it's the version to target.
You can go from 4.2 to 5.2 in one step. Read the 5.0, 5.1 and 5.2 release notes in that order, because each one removes what the previous ones deprecated.
This guide comes from the official release notes plus what we hit upgrading three real open-source Django apps. In all three, the tests passed. In one of them, two serious production bugs were still hiding.
Before you start
- Python 3.10 or newer. Django 5.x dropped Python 3.8 and 3.9. If you publish a package or support self-hosters on older Python, dropping it is a decision to make up front.
- Check your database version. Django 5.2 needs PostgreSQL 14+, MariaDB 10.5+, MySQL 8.0.11+ or SQLite 3.31+. Check production, not just your laptop.
- Run the tests on 4.2 with warnings on.
python -Wd manage.py test(orpytest -W error::DeprecationWarning) shows everyRemovedInDjango50WarningandRemovedInDjango51Warning. Fix these while still on 4.2 and most of the upgrade is done. - List your add-ons. Django REST Framework, django-filter, django-allauth and similar packages need compatible versions too. Upgrade them in the same change.
The silent ones: settings Django now ignores
These cause the worst bugs, because nothing crashes.
File storage settings (removed in 5.1)
DEFAULT_FILE_STORAGE and STATICFILES_STORAGE were removed in Django 5.1. If they're still in your settings, Django ignores them with no error and falls back to the defaults.
In one app we upgraded, that meant production would have stopped serving compressed static files, and users who'd set up S3 for photo uploads would have had their uploads silently written to local disk instead. All tests passed.
Move both to the STORAGES setting:
# Before
DEFAULT_FILE_STORAGE = "storages.backends.s3.S3Storage"
STATICFILES_STORAGE = "whitenoise.storage.CompressedManifestStaticFilesStorage"
# After
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"},
"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"},
}
Search every settings file, including ones that build these values from environment variables. Also replace get_storage_class() (removed in 5.1) with django.core.files.storage.storages.
pytz time zones (removed in 5.0)
Django 5.0 removed support for pytz time zones. If middleware or code activates a pytz zone, local times come out wrong for every user. One app we upgraded showed wrong dashboard totals because of this. Switch to the standard library:
# Before
import pytz
timezone.activate(pytz.timezone(user.tz))
# After
import zoneinfo
timezone.activate(zoneinfo.ZoneInfo(user.tz))
Also replace django.utils.timezone.utc with datetime.timezone.utc.
Defaults that changed
USE_TZnow defaults toTrue. If your settings never set it, behaviour changes. Set it explicitly.USE_L10Nis gone; localized number and date formatting is always on. Check templates that print numbers.- Forms render with
<div>s instead of tables by default. Check custom CSS and form templates. - MySQL connections use
utf8mb4by default in 5.2. A legacyutf8mb3database can set the charset inOPTIONSuntil it's converted.
Removals that crash (the easy ones)
These raise errors, so tests usually find them. Search for each anyway.
| Removed | Use instead | Removed in |
|---|---|---|
index_together in models and old migrations |
Meta.indexes |
5.1 |
assertFormError(response, "form", ...) |
assertFormError(response.context["form"], ...) |
5.0 |
assertQuerysetEqual |
assertQuerySetEqual |
5.1 |
length_is template filter |
{% if value|length == 4 %} |
5.1 |
| Logging out with a GET link | a POST form to the logout view | 5.0 |
CSRF_COOKIE_MASKED, SERIALIZE test setting |
remove them | 5.0 |
PickleSerializer for sessions |
JSON serializer | 5.0 |
is_dst argument to make_aware() and Trunc*() |
remove it | 5.0 |
index_together in old migrations is the one that surprises people: historic migrations that used it break fresh installs and test databases. In one app we fixed two migrations by making those operations no-ops, which matched what the project's maintainers later did themselves.
Add-on packages
Upgrade the whole set together and read each changelog. Two we hit:
- Django REST Framework 3.15+ makes a duplicate router
basenamean error at startup. - Packages that ship new migrations. One settings package needed new migrations in its new major version, and those fail if the production database has duplicate rows. Check before you deploy.
Private Django internals
If your code uses underscore-prefixed Django internals, assume they changed. One app we upgraded built its plugin system on Signal._live_receivers(). Django 5.0 changed what it returns, and every plugin signal in the app broke at once. Search for ._ attribute access on Django objects.
Get ahead of Django 6.0
Two cheap things now save work on the next upgrade:
forms.URLFieldwill assumehttps://instead ofhttp://for URLs typed without a scheme in Django 6.0. If the current behaviour matters, pin it withassume_scheme="http".- Run the tests with warnings on again and clear every
RemovedInDjango60Warning.
Upgrade checklist
- Python 3.10+ and supported database versions in production
- Tests green on 4.2 with deprecation warnings fixed
- Django and all add-ons bumped together
STORAGESreplacesDEFAULT_FILE_STORAGEandSTATICFILES_STORAGE- pytz replaced with zoneinfo;
USE_TZset explicitly - Every removal in the table searched for and fixed, including old migrations
manage.py makemigrations --checkcleanmanage.py check --deployon production settingscollectstaticrun and one file uploaded against the real storage backend