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

  1. 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.
  2. 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.
  3. Run the tests on 4.2 with warnings on. python -Wd manage.py test (or pytest -W error::DeprecationWarning) shows every RemovedInDjango50Warning and RemovedInDjango51Warning. Fix these while still on 4.2 and most of the upgrade is done.
  4. 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_TZ now defaults to True. If your settings never set it, behaviour changes. Set it explicitly.
  • USE_L10N is 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 utf8mb4 by default in 5.2. A legacy utf8mb3 database can set the charset in OPTIONS until 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 basename an 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.URLField will assume https:// instead of http:// for URLs typed without a scheme in Django 6.0. If the current behaviour matters, pin it with assume_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
  • STORAGES replaces DEFAULT_FILE_STORAGE and STATICFILES_STORAGE
  • pytz replaced with zoneinfo; USE_TZ set explicitly
  • Every removal in the table searched for and fixed, including old migrations
  • manage.py makemigrations --check clean
  • manage.py check --deploy on production settings
  • collectstatic run and one file uploaded against the real storage backend

Sources