How Django dispatches async and sync views under WSGI and ASGI

Django decides how to run a view from its signature. A view defined with async def is a coroutine function, and the request handler checks for that at dispatch time. Everything else about how the view executes depends on whether the server speaks ASGI or WSGI.

Under ASGI, Django's handler is itself a coroutine running on the server's event loop. An async view is awaited directly on that loop. While it waits on network I/O, the loop can serve other requests. A sync view cannot run on the loop without blocking it, so Django wraps it in sync_to_async with thread sensitivity enabled. The view then runs in a worker thread while the loop keeps going. Middleware gets the same treatment. Each async/sync boundary in the chain costs a thread hop.

ASGI server (event loop)
        |
        v
  ASGIHandler (async)
        |
        v
  middleware chain ---- sync middleware? --> thread hop
        |
        +-- async def view --> awaited on loop
        |        |
        |        +-- sync_to_async(ORM call) --> worker thread --> database
        |
        +-- def view --> sync_to_async --> worker thread --> database

Under WSGI, the server gives Django one thread per request and expects a plain return value. To run an async view in that model, Django wraps it with async_to_sync, which starts a fresh event loop for that request and shuts it down afterwards. Nothing else shares that loop, so concurrency is still capped by WSGI workers and threads. You pay for loop setup and context switching and get no overlap in return.

# views.py: report where each view runs. Compare the output under
#   ASGI: uvicorn myproject.asgi:application
#   WSGI: gunicorn myproject.wsgi:application
import asyncio
import threading

from asgiref.sync import sync_to_async
from django.http import JsonResponse

from .models import Order  # placeholder model


def where():
    # Report the current thread and, if one is running, the event loop.
    try:
        loop_id = id(asyncio.get_running_loop())
    except RuntimeError:
        loop_id = None  # no loop: plain sync code in a thread
    return {"thread": threading.current_thread().name, "loop_id": loop_id}


async def async_view(request):
    on_entry = where()
    # Simulated network I/O. Under ASGI the loop serves other requests meanwhile.
    await asyncio.sleep(0.1)
    # Sync ORM call pushed to a worker thread so it does not block the loop.
    count = await sync_to_async(Order.objects.count)()
    # ASGI: loop_id stays the same across requests (the server's shared loop).
    # WSGI: async_to_sync builds a new loop per request, so loop_id
    # usually changes. CPython can reuse an id after a loop is freed.
    return JsonResponse({"view": on_entry, "orders": count})


def sync_view(request):
    # ASGI: runs in a worker thread via sync_to_async, so loop_id is None.
    # WSGI: runs directly in the server's request thread.
    return JsonResponse({"view": where(), "orders": Order.objects.count()})

Two instrumented views that report their thread and event loop. Run under ASGI, async views share the server's loop and sync views move to worker threads. Run under WSGI, each async request gets a new event loop.

What the async ORM actually gives you

Django exposes an async variant of most queryset and model methods, marked with an a prefix. On querysets you get aget(), acreate(), acount(), afirst(), alast(), aexists(), aupdate(), adelete() and aget_or_create(). On model instances you get asave(), adelete() and arefresh_from_db(). A queryset also supports async for, so you can iterate over rows without wrapping the loop in an adapter. Methods that only build a query, such as filter(), exclude() and order_by(), stay synchronous because they never touch the database.

These methods let you await the ORM, but the calls are not non-blocking all the way down. For most backends, Django runs the query through a sync_to_async wrapper. The driver call still blocks a worker thread, and your coroutine yields while it waits. The adapter is thread-sensitive by default, which keeps connection handling consistent. It also means queries from one request run one after another on a shared thread rather than in parallel.

That is why an ORM-heavy view gains little. Each awaited query pays for a hop to the executor and a hop back. Database work is serialised either way, so ten sequential queries cost about what they did in a sync view, plus the hand-off overhead. The view can end up slightly slower.

Transactions are the most awkward case. transaction.atomic relies on connection state tied to the current thread, and Django does not support it directly in async code. The reliable pattern is to put the whole transactional unit in a plain sync function and call it once with sync_to_async. Every query in that function then runs on the same thread and connection.

from asgiref.sync import sync_to_async
from django.db import transaction
from django.http import JsonResponse

from shop.models import Order, Product


async def order_summary(request, order_id):
    # Each awaited call is still a blocking driver call on a worker thread.
    order = await Order.objects.aget(pk=order_id)
    item_count = await order.items.acount()
    first_item = await order.items.order_by("id").afirst()

    # filter() only builds the query; async for runs it and streams the rows.
    names = [p.name async for p in Product.objects.filter(order=order)]

    return JsonResponse({
        "status": order.status,
        "items": item_count,
        "first_item_id": first_item.id if first_item else None,
        "products": names,
    })


def _place_order(user_id, product_ids):
    # Plain sync function: atomic() and every query inside it share one thread
    # and one connection, so the transaction behaves as it does in sync code.
    with transaction.atomic():
        order = Order.objects.create(user_id=user_id, status="pending")
        products = Product.objects.select_for_update().filter(id__in=product_ids)
        order.products.set(products)
        return order.id


async def place_order(request):
    product_ids = [int(x) for x in request.POST.getlist("product_id")]
    # Hand the whole transactional unit to the sync world once, not query by query.
    order_id = await sync_to_async(_place_order)(request.user.id, product_ids)
    return JsonResponse({"order_id": order_id}, status=201)

An async view awaits a-prefixed ORM methods and iterates a queryset with async for, and a second view runs a transaction.atomic block in a sync function called once through sync_to_async.

Using sync_to_async, async_to_sync and avoiding SynchronousOnlyOperation

sync_to_async wraps a synchronous callable so it can be awaited. The work runs in a thread while the event loop continues serving other coroutines. Its default, thread_sensitive=True, sends every such call to one shared thread rather than a pool. This keeps thread-bound state consistent. Django database connections are per-thread, and much older code depends on thread-locals. Change it to thread_sensitive=False only when the wrapped code is self-contained and thread-safe, such as a sync HTTP request or a CPU-light parsing step that never touches the ORM. You gain parallelism and give up that consistency.

async_to_sync goes the other way. It lets sync code, such as a management command, a Celery task or a sync view, call a coroutine. Do not use it from a thread that already has a running event loop. It is meant for sync contexts only.

Django raises SynchronousOnlyOperation when async-unsafe code runs in a thread with an active event loop. The usual triggers are a sync ORM query, lazy relation access on a model instance, or iterating an unevaluated queryset. The same applies to sync-only code that uses the ORM internally, including custom authentication backends, signal receivers and many third-party apps. Wrap these calls rather than setting the environment override that disables the check.

The check only covers Django's own code. A sync HTTP client, open() on a slow disk, time.sleep, or a blocking SDK runs inside the loop without raising anything. While it runs, it stalls every concurrent request on that worker.

# views.py
import requests
from asgiref.sync import sync_to_async
from django.http import JsonResponse
from .models import Order

def _load_order(order_id):
    # ORM query and lazy FK access (order.customer) are sync-only.
    # Run them unwrapped in an async view and Django raises SynchronousOnlyOperation.
    order = Order.objects.get(pk=order_id)
    return {"id": order.pk, "customer": order.customer.email}

def _fetch_rate(currency):
    # Sync HTTP client. It never raises SynchronousOnlyOperation, but it blocks if run on the loop.
    resp = requests.get("", params={"c": currency}, timeout=5)
    resp.raise_for_status()
    return resp.json()["rate"]

async def order_detail(request, order_id):
    # Default thread_sensitive=True: runs on the shared thread that owns the DB connection.
    order = await sync_to_async(_load_order)(order_id)
    # No ORM and no thread-locals, so it is safe to run in a pool thread in parallel.
    rate = await sync_to_async(_fetch_rate, thread_sensitive=False)("EUR")
    # Calling requests.get() directly here would stall every request on this worker.
    return JsonResponse({**order, "rate": rate})


# management/commands/refresh_rates.py
from asgiref.sync import async_to_sync
from django.core.management.base import BaseCommand

async def refresh_rates():
    ...  # existing coroutine shared with async code paths

class Command(BaseCommand):
    def handle(self, *args, **options):
        # Sync context with no running loop, so async_to_sync is the right bridge here.
        async_to_sync(refresh_rates)()

An async view uses sync_to_async with the default thread_sensitive=True for ORM work and thread_sensitive=False for a self-contained sync HTTP call, and a management command uses async_to_sync to call a coroutine from sync code.

Auditing middleware and database connections for an async stack

Middleware is the next place blocking work hides. Django builds the middleware chain once at startup and checks each class for two attributes: sync_capable, which defaults to true, and async_capable, which defaults to false. Any middleware that does not declare async support gets adapted. Under ASGI, Django runs it through sync_to_async, then switches back to the event loop for the next async layer. A chain that alternates between sync and async middleware pays for a thread hop at every boundary, in both directions, on every request.

To audit the chain, read each entry in MIDDLEWARE, including those from third-party packages, and look for the sync_and_async_middleware or async_only_middleware decorators or for the attributes set directly. With the django.request logger at DEBUG level, Django also logs each adaptation it performs at startup.

A partially converted stack can be slower than a fully sync one. Under WSGI, a sync view and sync middleware run on one thread with no switching. An async view behind a single sync-only middleware still waits on a thread pool, and it also pays the cost of moving between the loop and that pool.

Concern WSGI, sync stack ASGI, async stack
Middleware adaptation None Per non-async middleware
CONN_MAX_AGE > 0 Reuses connections per worker thread Connections tied to executor threads; cleanup less predictable
Concurrent DB connections Bounded by threads Bounded by in-flight requests

Connection lifetime changes under ASGI too. Django opens and closes connections around request signals and tracks them per thread. Async ORM calls run in executor threads, so persistent connections may not be reused or closed the way they are under WSGI. The common safe default is CONN_MAX_AGE = 0.

Concurrency raises a second problem. Thousands of in-flight requests can each try to hold a connection and hit the database's connection limit. Pooling at the database or proxy layer caps that number no matter how many coroutines are waiting, and under ASGI that cap matters more than any per-worker setting.

# audit_middleware.py: run from the project root: python audit_middleware.py
import os

import django

os.environ.setdefault("DJANGO_SETTINGS_MODULE", "mysite.settings")  # placeholder project
django.setup()

from django.conf import settings
from django.utils.deprecation import MiddlewareMixin
from django.utils.module_loading import import_string

for path in settings.MIDDLEWARE:
    mw = import_string(path)
    # Same defaults Django applies when it builds the chain.
    sync_ok = getattr(mw, "sync_capable", True)
    async_ok = getattr(mw, "async_capable", False)
    if not async_ok:
        verdict = "SYNC-ONLY: adapted with a thread hop under ASGI"
    elif not sync_ok:
        verdict = "async-only"
    else:
        verdict = "sync+async"
    # MiddlewareMixin declares async support, but its process_request and
    # process_response hooks still run through sync_to_async.
    if isinstance(mw, type) and issubclass(mw, MiddlewareMixin):
        verdict += " (MiddlewareMixin: sync hooks still hop threads)"
    print(f"{path:60} {verdict}")

# Persistent connections behave less predictably when ORM calls run in
# executor threads, so flag any alias that keeps connections open.
for alias, cfg in settings.DATABASES.items():
    age = cfg.get("CONN_MAX_AGE", 0)
    note = "" if age == 0 else "  <- review under ASGI; prefer external pooling"
    print(f"DB {alias}: CONN_MAX_AGE={age}{note}")

This standalone script loads the project settings, reports for each MIDDLEWARE entry whether Django will wrap it in a thread hop under ASGI, and flags database aliases that use persistent connections.

Deploying Django with ASGI: servers, workers and thread pools

under WSGI, where the worker count already bounded concurrency. That bound disappears once requests become coroutines, so the deployment layer has to supply it.

Choosing a server topology

Uvicorn, Daphne and Hypercorn can all serve myproject.asgi:application directly. Running Gunicorn with a Uvicorn worker class (gunicorn myproject.asgi:application -k <uvicorn worker class> -w 4) adds a mature process manager in front.

Setup Process supervision Typical fit
Uvicorn/Hypercorn/Daphne standalone Basic, or delegated to the orchestrator Containers with one process each, scaled by the platform
Gunicorn + Uvicorn workers Restarts crashed workers, graceful reloads, timeouts VMs or hosts running several workers per machine

Sizing workers and threads

Each worker runs one event loop on one core. Start with roughly one worker per core rather than the WSGI habit of two to four per core. Extra loops do not add I/O concurrency; they only add memory and database connections.

Every sync_to_async call borrows a thread. Calls in the default thread-sensitive mode share a single thread, so heavy sync ORM use queues behind it no matter how large the pool is. Non-sensitive calls use asgiref's executor, which you can size with the ASGI_THREADS environment variable. Keep that total within your database connection budget.

Observability

Stack traces stop at the sync_to_async boundary, and request context stored in thread-locals does not survive the hop. Confirm that your APM and logging libraries propagate contextvars across that boundary. Support for async Django varies between tools, so verify it in staging before you depend on it.

Keep wsgi.py

Keep wsgi.py and a tested Gunicorn sync-worker command available. If an async regression, a library that blocks the event loop, or a tooling gap appears in production, you can roll back by changing the deploy configuration instead of the code.

When async views win and when to avoid them

Async views pay off when a request spends most of its time waiting on I/O that is not the database and that work can overlap. The clear cases are:

  • Fan-out to external HTTP APIs with an async client, where independent calls run concurrently instead of one after another.
  • Long-polling, where a request waits on an event without tying up a worker thread.
  • Streaming responses and server-sent events, where many open connections would otherwise each hold a thread.

Illustrative estimate (assumed inputs, not measured): a view calls three APIs with assumed latencies of 120 ms, 200 ms and 150 ms. Run sequentially, the wait is about 470 ms. Run concurrently with asyncio.gather, the wait is bounded by the slowest call, about 200 ms plus some scheduling overhead.

Avoid async for ORM-dominated CRUD views. Each ORM call still crosses a sync boundary, so you add overhead without gaining concurrency. Avoid it for heavy transactional logic, because transactions have to run inside a single sync context, which pushes the whole block back into a thread anyway. Avoid it when you depend on sync-only packages, since wrapping every call removes the benefit and one unwrapped call can block the event loop.

Alternative Best for Trade-off
Well-tuned sync WSGI CRUD and transactional views Each slow request holds a worker thread
Background task queue Slow work the user doesn't need to wait for Adds a broker and workers to operate
Caching Repeated reads of the same upstream data Staleness and cache invalidation

Migration path and impact on cost and engineering time

An incremental plan

Treat the move as a server change first and a code change second. Switch deployment from WSGI to an ASGI server while leaving every view synchronous, then rerun your existing load tests. Sync views now execute through a thread pool, so this step isolates the overhead of the adapter layer from any benefit of async code. If latency or throughput regresses here, you find out before any business logic has changed.

Next, pick the few endpoints whose time is dominated by waiting, such as outbound HTTP calls, fan-out to several upstream services, or long-polling. Convert only those. Where the async ORM falls short, wrap the remaining ORM access in sync_to_async. Then profile again under realistic concurrency. Expand to more endpoints only when measurements in your own environment justify it.

What it costs

Most of the cost is engineering time:

  • Learning asyncio. Engineers need working fluency with the event loop, including cancellation and why a single blocking call stalls every request sharing that loop.
  • Tuning two layers. Operators now tune both server workers and the thread pool that runs sync code. A poor ratio shows up as queueing and rising latency, not as errors.
  • Harder debugging. Stack traces that cross sync/async boundaries are harder to read.
  • Compatibility audits. Every third-party library and middleware in the request path needs to be checked.

Why targeted conversion is cheaper

A full rewrite pays all of those costs across the whole codebase, even though the gains are concentrated in a handful of endpoints. A separate async service leaves Django untouched, but it adds another deployment, its own auth integration, and duplicated data models. Converting only the I/O-bound views keeps one codebase, one deploy and one data layer. It also limits the asyncio learning curve to the team that owns those endpoints.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to django async views, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.