JT Book a call

Blog

Migrating Enterprise Django Apps From 4.2 to 5.x

Published

7 min read

Migrating Enterprise Django Apps From 4.2 to 5.x
In this post

If you run Django 4.2, the clock has already run out. The 5.2 release notes say it plainly: “Support for the previous LTS, Django 4.2, will end in April 2026.” LTS means long-term support, the release line that gets security fixes for years. Today is October 2026, so a 4.2 app is no longer getting security patches from the Django project.

That changes what “migrate to Django 5.0” should mean. Django 5.0 is not an LTS line, and the supported-versions table no longer lists it. The target is 5.2 LTS, listed there with extended support to April 2028. The table also shows 6.0 and 6.1 as current, and a 6.2 LTS planned for April 2027.

The rest of this post is what I measured, not what the changelog promises. I built a small Django 4.2.30 project on Python 3.12, ran it with deprecation warnings on, then ran the same code on Django 5.2.18.

The upgrade path Django documents #

The official upgrade guide is short, and I would follow it as written:

  1. Read the release notes for every final release between your version and your target, especially the backwards-incompatible changes.
  2. Upgrade step by step. The guide recommends going through each feature release (4.2 to 5.0 to 5.1 to 5.2), using the latest patch of each, and says the same incremental approach applies from one LTS to the next.
  3. Resolve deprecation warnings on your current version first. They are silent by default; turn them on with python -Wa manage.py test.
  4. Upgrade your dependencies too. Some may not yet support your target.
  5. After deploying, consider clearing the Django cache: cached pickled objects are not guaranteed to be compatible across versions.

Python is the first gate. Django 5.0 supports Python 3.10 to 3.12, and the 5.0 notes say 4.2.x is the last series to support 3.8 and 3.9. Django 5.2 supports 3.10 through 3.14. If production is on Python 3.9, the Python upgrade comes first.

Step 1: Let 4.2 tell you what to fix #

My test project used three things that were common in 4.2-era code. I ran python -Wd manage.py test on 4.2.30 and got:

RemovedInDjango50Warning: The USE_L10N setting is deprecated. Starting with Django 5.0, localized formatting of data will always be enabled.
RemovedInDjango51Warning: The DEFAULT_FILE_STORAGE setting is deprecated. Use STORAGES instead.
RemovedInDjango51Warning: 'index_together' is deprecated. Use 'Meta.indexes' in 'orders.Order' instead.
Ran 1 test in 0.001s
OK

The tests pass and the warnings name the version that removes each feature. This is the cheapest part of the whole migration, and skipping it is what makes migrations painful. If your test suite is thin, the warnings still appear from manage.py check and from any code path your tests do reach.

Step 2: What happens if you skip it #

I ran the unchanged project on Django 5.2.18.

index_together is a hard failure. It was removed in 5.1. The project would not import:

TypeError: 'class Meta' got invalid attribute(s): index_together

The fix is Meta.indexes. I replaced it with a named models.Index(fields=["customer", "placed_at"], name="order_cust_placed_idx") and ran makemigrations on 4.2. Django generated 0002_rename_order_customer_placed_at_order_cust_placed_idx.py, described as “Rename unnamed index for (‘customer’, ‘placed_at’) on order to order_cust_placed_idx”. After that, a fresh database migrated cleanly on 5.2: 0001_initial (which still contains index_together in its options) and 0002 both applied, and makemigrations --check reported no changes.

So old migration files that mention index_together keep working. Only your live model code has to change, and it is best changed on 4.2 where Django writes the rename migration for you.

DEFAULT_FILE_STORAGE is silently ignored. It was removed in 5.1, and the removal does not raise. I set it to InMemoryStorage and printed the class behind default_storage:

SettingDjango 4.2.30Django 5.2.18
DEFAULT_FILE_STORAGE = "...InMemoryStorage"InMemoryStorageFileSystemStorage
STORAGES = {"default": {"BACKEND": "...InMemoryStorage"}, ...}InMemoryStorageInMemoryStorage

On 5.2 the old setting no longer has any effect, and uploads go to the local filesystem. If your setting named S3 or another cloud backend, you would find out when files stop appearing there. STORAGES works on 4.2 as well (the second row shows it), so you can make this change before upgrading. This is the finding I would put at the top of a migration checklist.

USE_TZ changed its default. The 5.0 notes list “The default value of the USE_TZ setting is changed from False to True”. I confirmed it with a bare settings.configure(): USE_TZ is False on 4.2.30 and True on 5.2.18. Projects made with startproject set it explicitly, so they are unaffected. A project that never set it will start storing and returning timezone-aware datetimes after the upgrade.

USE_L10N was removed in 5.0 and localized formatting is always on. A setting you no longer need can simply be deleted. The release notes state the removal; I did not test locale output.

What else the release notes list as removed or changed #

From the 5.0, 5.1 and 5.2 release notes. I read these and did not run each one, so grep your code for the names before relying on the list.

  • 5.0 removed: pytz timezone support and the is_dst arguments; django.contrib.sessions.serializers.PickleSerializer; logging out via GET in LogoutView; QuerySet.iterator() on a queryset that prefetches related objects without chunk_size; passing unsaved model instances to related filters.
  • 5.0 changed: default form and formset rendering is now div-based; admin header markup moved from <h1> to <div>, which matters if you have admin tests or CSS that depend on it; MySQL below 8.0.11 is no longer supported.
  • 5.1 removed: index_together; DEFAULT_FILE_STORAGE and STATICFILES_STORAGE; get_storage_class(); BaseUserManager.make_random_password(); the length_is template filter; the SHA1 and unsalted MD5 password hashers. PostgreSQL 12 and MariaDB 10.4 support ended; minimum SQLite became 3.31.0.
  • 5.2 changed: PostgreSQL 13 support ended; MySQL connections default to utf8mb4 instead of utf8; QuerySet.create() and get_or_create() now have alters_data=True, so templates cannot call them.

The release notes remove those hashers outright. If PASSWORD_HASHERS or your user table still contains any of them, find out before moving to 5.1 or later. I did not test what login does with such a hash.

What the upgrade gives you #

The earlier version of this post claimed “native async ORM” arrived in Django 5.0 and quoted benchmark gains. Both were wrong, and I removed them:

  • The async ORM interface arrived in Django 4.1, so it is already in 4.2. I checked: on 4.2.30, await Order.objects.acreate(...), aget and async for over a queryset all ran.
  • It is not a speed-up by itself. The 4.1 release notes say that “at this stage, the underlying database operations remain synchronous”. The a-prefixed methods save you from wrapping calls in sync_to_async, and that is all they promise. Measure your own workload before expecting latency changes.

What the notes do list as new:

  • 5.0: Field.db_default (a database-computed default, for example db_default=Now()) and GeneratedField (a column computed by the database); facet counts in the admin changelist; field-group templates for forms; Signal.asend().
  • 5.1: the {% querystring %} template tag for building links that keep existing query parameters, and connection pool support for PostgreSQL with psycopg.
  • 5.2: composite primary keys, and a shell command that imports your models automatically.

None of these needs a rewrite. Adopt them on the pages you touch anyway.

Order of work #

  1. Move to the latest 4.2.x and the Python version your target supports.
  2. Run tests and manage.py check with -Wa and fix every RemovedInDjango50Warning and RemovedInDjango51Warning, starting with storage settings and index_together.
  3. Upgrade one feature release at a time to 5.2, running the tests at each step.
  4. Set USE_TZ explicitly, and check PASSWORD_HASHERS and stored hashes for the removed hashers.
  5. Deploy to a staging environment that writes a real file upload and a real password login before production.

Where I stopped #

I used a one-app project with SQLite. I did not test PostgreSQL or MySQL behavior, third-party packages, async views under a real server, or the 6.x series. A real enterprise app will hit things this small project cannot, which is why the release notes for each step are worth reading in full.

Sources #

Related: Django technical debt cost calculator.

Read next

Tell us what you need built

JetThoughts has been building software since 2008. Book a call, or write to sales@jetthoughts.com. You'll leave the call with a written one-page scope within 2 working days.

#01 Paul Keen
Paul KeenFounder & Open Source Contributor

A 30-minute call; pick a time.