Django Async ORM Migration: What Breaks and When to Stay Sync
Last updated: May 28, 2026
Django async ORM migration effort usually costs more than changing views to async def: teams must count every sync/async boundary around ORM calls, cache access, middleware, templates, and third-party packages. In Django 5.x, async views and async ORM methods exist, but Django still documents adaptation costs and async-safety limits. Migration is worth considering for endpoints dominated by multiple slow external I/O calls; ORM-heavy CRUD, admin, and template-rendered paths usually stay cheaper, simpler, and faster as sync code.
Django async ORM migration looks like a straightforward concurrency win, but the adaptation cost Django itself documents at every sync/async boundary turns that assumption inside out. Partial async migration typically costs more throughput than it saves because each sync_to_async crossing — ORM calls, cache reads, middleware, template rendering, third-party packages — adds thread dispatch and scheduling overhead while the original blocking work stays unchanged. The exception: async does pay off for endpoints dominated by multiple slow external API calls where the event loop can hide network wait time. For most ORM-heavy Django apps, the practical move is to keep views sync, optimize queries and worker counts, and reserve async conversion for the few routes where external I/O genuinely dominates p95 latency.
More on Django Async Orm Migration Effort.
- Django supports async views, but its own async docs warn that crossing between sync and async code has a performance cost.
- The Django ORM has async APIs, yet Django’s ORM async documentation still describes async safety limits and patterns that matter for real migrations.
asgiref.sync.sync_to_async()defaults to thread-sensitive execution, which preserves safety for code such as database access but can serialize work through a constrained execution path, as shown in asgiref’s sync adapter source.- If a view does several ORM calls, a sync cache call, and one external async API request, the migration cost is often multiple sync/async boundaries, not one
async def. - Python free-threading work from PEP 703 changes the long-term tradeoff by making sync parallelism a serious part of the planning discussion.
The Migration Cost Everyone Measures Wrong
Django async migration is usually priced as a source-code conversion: change views to async def, run under ASGI, replace blocking libraries, and add awaits. That is the wrong unit. The real unit is the boundary between async request handling and sync framework code.
Django’s own asynchronous support documentation is careful about this. It says Django can run async views under ASGI, but it also describes the penalty for switching between sync and async modes. That warning is not a footnote. It is the migration budget.
Background on this in real-world async latency gains.
A fully async stack can be coherent. A fully sync stack can also be coherent. The expensive state is the middle one: async views calling sync ORM sections, sync middleware wrapping async views, async code touching sync cache clients, and template rendering that pulls you back into sync execution. Real Django projects live in that middle state for a long time because the app code, framework code, and package ecosystem do not move together.
That is why “async Django is ready” is an incomplete claim. Ready for what? Ready for a narrow long-polling endpoint that awaits external HTTP calls? Yes. Ready for an existing admin-heavy, ORM-heavy, template-rendered product to become faster after a broad async rewrite? Usually no.

The problem is layered. The weak point is not “Django async” as a label; it is the number of sync-only layers that a request still touches after the view has moved to async def. As the diagram below shows, each layer — ORM, cache, middleware, templates, packages — can independently force a boundary crossing.

Notice where the expensive lines fall: not where async appears, but at the call sites where an async view crosses back into sync execution for database, cache, template, or package code. Those crossings are the part most migration estimates omit.
Key takeaway: count boundary crossings per request before you count async functions per file.
Inside sync_to_async: What Actually Happens When You Cross the Boundary
sync_to_async is not a magic non-blocking adapter. It schedules synchronous callable execution so async code can wait for the result without blocking the event loop directly. That protects the loop, but it does not erase the cost of threads, context handling, blocking I/O, or Python execution.
The implementation lives in the asgiref sync adapter source, which Django uses for sync/async interop. The source shows the central shape: SyncToAsync wraps a sync callable, captures execution context, and runs the callable through an executor path while the async caller awaits the result.
The safety default matters. sync_to_async() defaults to thread-sensitive behavior, as documented in Django’s async guide and implemented in asgiref. That default exists because large parts of traditional Django code expect thread affinity, especially database access patterns. Safety is the right default, but safety is not free.
from asgiref.sync import sync_to_async
from django.shortcuts import get_object_or_404
async def account_detail(request, account_id):
account = await sync_to_async(
get_object_or_404
)(Account, pk=account_id)
balance = await sync_to_async(
lambda: account.ledger_entries.order_by("-created_at").first()
)()
return JsonResponse({
"account": account.id,
"latest_balance": balance.amount if balance else None,
})
This example is intentionally plain. It is the kind of code teams write during migration because rewriting every query, helper, and model method in one pass is not realistic. The view is async, but the core work still happens behind sync adapters.
The result is not “async ORM performance.” It is async request handling waiting on sync ORM work. Under light load, the difference may be small enough to miss. Under concurrent load, the adapter path competes with real request work for threads, scheduling time, and the GIL in normal CPython builds.
That is also why raw microbenchmarks of final-state async endpoints can mislead. They measure the destination. Migration cost is paid on the road, where your code has both execution models active at once.
The Django Async Readiness Map Nobody Publishes
Django’s async support is real, but uneven across the stack. The migration mistake is treating async views as if they convert the whole request path. They do not. A request only stays async if every layer it touches can participate without forcing a sync bridge.
The official Django docs on async safety explain why some Django parts are protected from async contexts. That protection is a sign that the framework is doing the correct thing for data safety. It is also evidence that full-stack async migration is not just a search-and-replace project.
Related: async prefetch pitfalls in Django 5.2.
The database layer is where expectations often break. Psycopg 3 has documented async connection and cursor support, but a Django project’s behavior depends on Django’s backend path, transaction handling, and the code around the query. You do not get a fully async application merely because the database driver has an async API somewhere in its own docs.
The Partial Migration Tax: Why Half-Async Is Worse Than Full-Sync
Half-async Django can be slower than full-sync Django because it adds adapter work without removing the blocking work. A sync worker blocks honestly. A partially migrated async view can block indirectly, after paying for event-loop coordination and sync execution dispatch.
Consider a common product endpoint: load the user account, fetch several related objects, read a feature flag from cache, call a billing API, then render or serialize the response. In a sync Django app, that is boring but direct. In a partially async app, only the billing API may be naturally async.
Related: sub-interpreters and GIL-free memory sharing.
import httpx
from asgiref.sync import sync_to_async
from django.core.cache import cache
from django.http import JsonResponse
async def billing_panel(request):
account = await Account.objects.aget(user=request.user)
plan = await sync_to_async(
lambda: account.subscription.plan
)()
feature_flags = await sync_to_async(
cache.get
)(f"flags:{account.id}", {})
async with httpx.AsyncClient(timeout=3.0) as client:
invoice_response = await client.get(
f"https://billing.example.internal/accounts/{account.id}/latest-invoice"
)
return JsonResponse({
"account_id": account.id,
"plan": plan.name,
"flags": feature_flags,
"invoice": invoice_response.json(),
})
This code has one async-native operation that likely benefits from the event loop: the HTTP call. It also has ORM and cache work that can pull execution back toward sync behavior. If the billing call is a long external wait, async may still win because the event loop can keep other requests moving while the network waits. If the endpoint is mostly database-bound, the bridge tax can dominate.
The cost is not only latency per call. It is contention under concurrency. Each bridge consumes scheduling capacity and can concentrate work in thread-sensitive paths. The app starts to look like a sync app with extra coordination overhead.
A useful migration estimate should count these items per endpoint:
- Number of ORM access points, including lazy relation access outside the obvious query line.
- Number of sync cache, storage, search, email, payment, and audit-log calls.
- Whether middleware forces Django to adapt the whole request path.
- Whether template rendering or serializers trigger lazy database work after the async section.
- Whether the endpoint waits on external I/O long enough to offset bridge overhead.
The last item is the one that decides the project. Async is a latency-hiding tool for concurrent waiting. It is not a general discount on CPU-bound Python, ORM-heavy request processing, or template rendering.

Read the heatmap above as a budget map: green zones are endpoints with long external waits and few sync dependencies; red zones are endpoints where async syntax wraps sync framework work. The costly category is not “Django” or “async” by itself. It is mixed execution inside high-traffic endpoints.
Free-Threading Changes the Entire Calculation
Python’s free-threading work weakens one of the main arguments for rushing Django async migration. If sync Python code can run with less GIL restriction in supported builds, scaling traditional sync Django across threads becomes more interesting than rewriting request paths around async boundaries.
PEP 703 accepted making the Global Interpreter Lock optional in CPython, and Python 3.13 added experimental free-threaded builds. The feature is not a reason to run your production Django app on a free-threaded interpreter tomorrow. It is a reason to avoid spending months on an async rewrite whose main benefit may shrink as the interpreter changes.
measuring contention in free-threaded builds goes into the specifics of this.
This matters for Django because many apps are not dominated by one slow external service. They are dominated by ORM work, permission checks, serializers, Python business logic, and third-party packages. Async does not make CPU-bound Python execute in parallel under the traditional GIL. Free-threading targets that deeper bottleneck.
The risk is opportunity cost. If your team spends a long migration cycle converting a large Django codebase to partial async, you may end with more complex call stacks just as CPython makes sync-threaded execution more attractive. That does not mean “wait forever.” It means treat async migration as a targeted endpoint decision, not a platform-wide default.
The Decision Rubric: When Async Migration Actually Pays Off
Async migration pays when the request spends most of its time waiting on multiple external I/O operations that already have async-safe clients. It usually does not pay when the request is ORM-heavy, template-heavy, cache-heavy, or dependent on sync-only middleware.
My recommendation is strict: migrate endpoints, not applications. Start with one route where p95 latency is dominated by external waiting, not database time. Keep the rest sync until profiling proves that a boundary crossing is cheaper than the wait it hides.
A practical rule: if a view’s slowest work is inside PostgreSQL, Redis, template rendering, or Python serialization, async is probably the wrong first move. Add indexes, reduce queries, tune Gunicorn or uWSGI worker counts, cache whole fragments, or move work out of the request.
What to Do Instead of Migrating Today
The better near-term plan for many Django teams is not “never async.” It is “make sync fast, then isolate the few async-shaped endpoints.” That keeps migration effort proportional to the actual concurrency problem.
For a conventional Django app, the first fixes are usually unglamorous and effective: reduce N+1 queries, use select_related() and prefetch_related(), move slow side effects to Celery or another worker system, cache expensive read paths, and scale worker processes. Those changes help sync and async code.
More detail in CPython’s JIT in production workloads.
Use async where the shape fits. A Server-Sent Events endpoint streaming Local LLM output, a webhook fan-out route calling several vendor APIs, or a dashboard panel waiting on multiple internal services can justify async views. Those are endpoints where the event loop is solving a real waiting problem.
Do not convert admin, template-rendered CRUD pages, or ORM-heavy JSON endpoints just to make the codebase look current. That gives you function coloring, adapter cost, harder debugging, and weaker package compatibility without a matching runtime payoff.
What is the real cost of a Django async ORM migration?
The real cost is the number of sync/async boundaries left on hot request paths. Count ORM calls, lazy relations, cache reads, middleware mode switches, template rendering, and sync-only packages. If those remain after adding async def, the migration adds adapter overhead before it removes blocking work.
When should a Django endpoint stay sync instead of moving to async?
Stay sync when p95 latency is dominated by database queries, serializers, permissions, templates, Redis calls, or Python business logic. In those cases, indexes, query reduction, select_related(), prefetch_related(), worker tuning, and background jobs usually reduce cost with less risk than a partial async rewrite.
When does async Django migration actually pay off?
Async Django pays off when an endpoint waits on multiple external I/O operations that already have async-safe clients, such as vendor APIs, streaming responses, long-polling, or service fan-out. The event loop helps when it can overlap waits; it does little for ORM-heavy work that still runs through sync adapters.
Methodology and source check
This source check verified Django’s async documentation, Django’s async safety guidance, asgiref’s sync adapter implementation, Psycopg 3’s async driver documentation, and PEP 703’s free-threading proposal against the migration-cost model used here. The comparison dimensions were execution boundary count, async-native layer availability, dependency safety, and whether async removes blocking work or merely wraps it.
The limitation is that this is not a benchmark of a single production app. Django projects vary too much by middleware, database usage, cache client, template use, and package set for one number to be honest. The reusable method is to count crossings per hot endpoint, then profile before and after a targeted conversion.
The strongest counter-argument
The strongest objection is that Django async is already useful in production, and avoiding it because parts of the stack remain sync leaves performance on the table. That objection is right for endpoints dominated by slow external I/O, streaming, or high connection counts.
The rebuttal is scope. Django’s own docs support async use, but they also document adaptation costs and async-safety boundaries. The asgiref adapter exists because mixed stacks are real, not because the boundary is free. Psycopg 3’s async support proves that async database access is possible, but it does not make every Django model method, transaction pattern, middleware, cache call, and third-party package async-safe.
So the correct conclusion is not anti-async. It is anti-blind-migration. Async Django is a good tool for endpoints that spend most of their time waiting outside Django. It is a poor default migration plan for ORM-heavy applications where the expensive work stays sync.
The real migration effort is not the number of files touched. It is the number of sync boundaries left on the hot path after the rewrite. If that count is high, keep the endpoint sync and spend the budget on query reduction, worker scaling, background jobs, and profiling. If the count is low and external waits dominate p95 latency, migrate that endpoint deliberately.
For most teams, the right current decision is targeted async plus a watchful eye on CPython free-threading. Do not buy a months-long partial migration unless the endpoint-level math proves the bridge toll is smaller than the wait it hides.
References
- Django asynchronous support guide — official Django guidance on async views, adapters, and performance costs.
- Django async safety documentation — official explanation of async-unsafe areas and protection rules.
- asgiref sync adapter source — implementation evidence for
sync_to_asyncandasync_to_syncbehavior. - Psycopg 3 async documentation — primary driver documentation for async connections and cursors.
- PEP 703: Making the Global Interpreter Lock optional in CPython — primary proposal behind CPython free-threading work.
- Python 3.13 free-threaded CPython notes — official Python documentation for experimental free-threaded builds.
