Every few weeks we get a call that starts the same way: "We have a Django app that's about eight years old. It works, but deploys are scary, performance has plateaued, and we can't hire people who want to work on it." That codebase usually ships real revenue. It also usually runs on Django 3.x, mixes sync and async in unhelpful ways, has accumulated a hundred circular imports, and has a test suite that takes twenty minutes to run.
This is a playbook for what to do about that — without a big-bang rewrite, without freezing feature work for six months, and without hoping that "we'll fix it later." It's the same approach we use on rescue projects at our Django product engineering practice.
01Why async matters in 2026
Async Django stopped being a curiosity around 2022 and became a mainstream production capability around 2024. In 2026 it is, for a meaningful class of applications, the difference between sustainable infrastructure costs and an ever-growing AWS bill.
The specific situations where async earns its keep:
- Endpoints that fan out to multiple slow services (LLM APIs, third-party SaaS, internal microservices). A sync view holding a worker process for 800 ms while it waits on an HTTP call is doing nothing useful.
- Streaming responses — LLM chat, server-sent events, long-running downloads. Sync Django can do these, but the worker model fights you.
- WebSockets and bidirectional connections, where Django Channels' ASGI runtime is dramatically more efficient than the equivalent sync setup.
- High-concurrency, low-CPU workloads — webhook receivers, proxy layers, API aggregators — where you'd otherwise be paying for idle worker processes.
Where async does not help: CPU-bound work (use Celery), simple CRUD endpoints over a fast database (sync is fine and often faster), and any code path where blocking I/O has already been pushed out of the request cycle.
If your app talks to LLMs, third-party APIs, or streams data, async will pay back the migration cost within months. If your bottleneck is database queries or in-process compute, async won't help — fix the queries first.
02Django 3 → 5 migration path
The single most common starting point for our rescue projects is Django 3.2 LTS — released in 2021, end-of-life in April 2024. If you're still on it, you're running unsupported software in production. The path to Django 5.x is well-trodden, but it's a path, not a jump.
Skip-version upgrades are tempting and almost always wrong. Each minor version has its own deprecation warnings, and the only way to surface them safely is to run the test suite (and ideally the app, in a staging environment) against each step.
# Recommended path — one minor version at a time # 3.2.x → 4.0 → 4.1 → 4.2 (LTS) → 5.0 → 5.1 → 5.2 (LTS) pip install "django==4.0.*" pytest --reuse-db # run full suite python -W error::DeprecationWarning manage.py check git commit -m "chore(django): bump to 4.0" # Repeat for 4.1, 4.2, 5.0, 5.1 — fixing deprecations as you go. # Stop at the next LTS and stabilise there before jumping again.
For each step, do four things in order:
- Update
requirements.txtorpyproject.toml. Run the test suite. Fix anything red. - Run
python -W error::DeprecationWarning manage.py check. Treat every warning as a bug to fix before the next step. - Deploy to staging behind a feature flag if you have one, or to a canary environment. Watch error rates for at least one business cycle.
- Commit. Push. Move to the next minor version.
Two specific pitfalls we hit on almost every project:
- Old
USE_L10Nsetting — removed in Django 5.0. If you still have it insettings.py, delete it. Localisation is now always-on. DEFAULT_AUTO_FIELD— Django 3.2+ defaults toBigAutoField. If your existing models were created withAutoField, setDEFAULT_AUTO_FIELD = "django.db.models.AutoField"in settings — or you'll get a migration storm.
03Untangling circular imports
Long-lived Django apps almost always accumulate circular imports. A typical pattern: accounts imports from billing, which imports from accounts, both via lazy apps.get_model() calls that hide the cycle from the type checker but materialise it at runtime. These are technical debt with compound interest.
The cleanup pattern we use:
# accounts/services.py from billing.models import Subscription def cancel_user(user): Subscription.objects.filter(user=user).update(active=False) user.is_active = False user.save()
The dependency from accounts on billing is the wrong way around. billing knows about users; users shouldn't know about subscriptions. We invert it with signals or an event bus:
# accounts/services.py — no longer knows billing exists from django.dispatch import Signal user_deactivated = Signal() def cancel_user(user): user.is_active = False user.save() user_deactivated.send(sender=User, user=user) # billing/handlers.py — listens, doesn't drag accounts into its imports from django.dispatch import receiver from accounts.services import user_deactivated from .models import Subscription @receiver(user_deactivated) def cancel_subscription(sender, user, **kwargs): Subscription.objects.filter(user=user).update(active=False)
A few rules we apply consistently:
- Treat each Django app as a bounded context. If app A needs data from app B, that's fine — but app A should never import app B's internal services or business logic.
- Public surface for an app is its models, its signals, and a single
services.pymodule. Everything else is implementation detail. - If two apps depend on each other in both directions, one of them is probably actually two apps.
- Use
pydepsorimport-linterin CI to fail the build when a forbidden import appears.
04Async DRF patterns
Django REST Framework added async view support properly in 2024. It is genuinely production-ready — but only if you use it where it earns its keep. The pattern we use most often is mixing sync and async views in the same project, with async reserved for the endpoints that benefit.
from rest_framework.views import APIView from rest_framework.response import Response from asgiref.sync import sync_to_async import httpx class SummariseView(APIView): # Async view — fans out to multiple slow services async def post(self, request): text = request.data["text"] async with httpx.AsyncClient() as client: summary, tags = await asyncio.gather( client.post("https://api.openai.com/v1/...", json={...}), client.post("https://api.cohere.ai/v1/...", json={...}), ) # Talking to the ORM from async still needs sync_to_async result = await sync_to_async(self._save)(request.user, summary, tags) return Response(result) def _save(self, user, summary, tags): return Summary.objects.create(user=user, body=summary, tags=tags)
The crucial detail: in Django 5.x, ORM async is partial. Model.objects.aget(), .acreate(), .aupdate() all exist, but anything that traverses relationships still needs care. We use sync_to_async for any non-trivial ORM call until proven otherwise — it's the safer default.
Three operational rules we apply:
- Run on ASGI (Uvicorn or Daphne), not WSGI (Gunicorn sync workers). Mixing async views with sync workers gives you the worst of both worlds.
- Don't async-ify Celery tasks. They run in a separate worker pool; sync code there is fine and usually clearer.
- Set a sensible
DATABASES["default"]["CONN_MAX_AGE"]— async views can multiply your connection count if persistent connections aren't tuned.
05Testing async Django
The most common mistake we see in async migrations: people write async views and test them with the regular sync Django test client. The tests pass; production breaks at the first concurrent request. Async code needs async tests.
import pytest from django.test import AsyncClient @pytest.mark.asyncio @pytest.mark.django_db(transaction=True) async def test_summarise_view(user_factory, mock_openai, mock_cohere): user = user_factory() client = AsyncClient() await client.aforce_login(user) response = await client.post( "/api/summarise/", data={"text": "Long article..."}, content_type="application/json", ) assert response.status_code == 200 assert await Summary.objects.acount() == 1
The three rules of testing async Django:
- Use
AsyncClientfromdjango.test, not the legacy syncClient, for async views. - Mark database tests with
transaction=True. Async views can hit the database from multiple coroutines, and the default per-test transaction isolation gives misleading results. - Test concurrency explicitly. Fire two requests in parallel from the same test and assert the outcomes don't conflict — this catches race conditions that sequential tests miss.
For a deeper dive on test architecture, including factories over fixtures and transaction isolation, see how we test Django applications.
06Cost & performance gains
Numbers from a recent rescue — an existing Django 3.2 LTS application serving a B2B SaaS with API endpoints that fan out to LLM providers and a search service. Before/after migration to Django 5.1, async views on the fan-out endpoints, kept sync everywhere else.
Before // django 3.2 sync
- p50 latency: 820 ms
- p95 latency: 2.1 s
- Gunicorn workers needed at peak: 48
- EC2 spend / month: £1,840
- Concurrent requests per worker: 1
After // django 5.1 mixed sync + async
- p50 latency: 240 ms ↓ 71%
- p95 latency: 680 ms ↓ 68%
- Uvicorn workers at peak: 8 ↓ 83%
- EC2 spend / month: £420 ↓ 77%
- Concurrent requests per worker: ~200
The pattern is consistent across the rescues we run — the bigger your fan-out, the bigger the win. For pure CRUD applications the gain is much smaller (typically 10-20% latency improvement, minimal cost change). The decision factor isn't "is Django 5 faster than Django 3?" It's "is your workload I/O-bound enough for async to matter?"
For I/O-heavy workloads, expect 50-80% latency reduction on hot endpoints and 3-6× worker density. For CRUD-heavy workloads, the win is smaller — but you still get current LTS, modern Python, and a codebase your team wants to work on.
07Refactor vs. rewrite
The temptation on every legacy Django project is the rewrite. Start fresh, get it right this time. We have done many rewrites — they fail more often than they succeed. The reason is simple: a working codebase, however ugly, contains years of accumulated business logic that nobody remembers explicitly. A rewrite reproduces the obvious 80%; the remaining 20% surfaces in production over the following 18 months.
Choose refactor when:
- The app ships real revenue and downtime has a real cost
- The team that wrote it has mostly left, but the code is still being deployed
- You have a working test suite (even if slow) or can build one incrementally
- The bottleneck is specific endpoints or modules, not the whole architecture
Choose rewrite when:
- The framework or runtime is genuinely incompatible with the new requirements (e.g. moving off Django entirely to a different stack)
- The data model itself is wrong for the current business — not a bit awkward, fundamentally wrong
- The codebase has no tests and the cost of building a safety net exceeds the cost of starting over
- You have the runway to maintain two systems in parallel for 12+ months
Of the rescue projects we've taken on in the last three years, the ratio has been roughly 9:1 in favour of refactor. The exception was always a case where the data model was the actual problem, not the framework version.
08Modernization checklist
What we run through on every rescue, in roughly this order:
- Inventory dependencies. Run
pip list --outdatedandpip-audit. Get a clear picture of CVEs before touching code. - Pin Python. Django 5.x requires Python 3.10+. Most legacy apps are still on 3.8 or 3.9. Bump Python first, in a separate PR.
- Get the test suite green on the current version before bumping anything. Untrustworthy tests poison every following step.
- Add a deprecation-warning-to-error trip-wire in CI so the next bump can't hide warnings.
- Bump Django one minor version at a time, all the way to the next LTS. Stop. Stabilise. Ship.
- Run
import-linteragainst your app graph. Document the boundaries you want; let CI enforce them. - Move WSGI → ASGI. Uvicorn or Daphne. Even if you don't add a single async view, ASGI gives you the runtime you'll need later.
- Identify the top 5 fan-out endpoints by p95 latency. Convert those to async. Leave the rest alone for now.
- Profile before celebrating. Measure p50, p95, worker count, cost — before and after. Async without measurement is hope, not engineering.
- Pair on the first PR. Whatever knowledge the original team had, it lives in the second-oldest engineer on the project. Pair with them.
None of this is glamorous. It is, however, what we've found actually works on production codebases people depend on. For most teams the right shape of engagement is a fixed-price technical audit first — three to five days of architecture review and a prioritised remediation plan — followed by a rolling 4-week build cadence to execute it.
If your Django app feels old and you're not sure whether to refactor or rewrite, tell us about it. We'll give you an honest read — including when the answer is "leave it alone, you have bigger problems."