In Django 5.0 and later, what changed for built-in view decorators on async def views, and which trap with condition() callbacks remains?
answer
- sync wrapper hid the coroutine
- iscoroutinefunction check per decorator
- method_decorator followed in 5.2
- callbacks are not awaited
- SynchronousOnlyOperation
basics
~20 sSince Django 5.0 the built-in view decorators detect async def views and return an async wrapper, so the view stays a coroutine function. condition() still calls its etag and last-modified callbacks synchronously, so they cannot be async or use the sync ORM.
solid answer
~40 sBefore 5.0, decorators such as `require_POST`, `vary_on_cookie`, `condition()` or `gzip_page` wrapped every view in a plain `def`, so Django saw a sync view, got an unawaited coroutine back, or the decorator tried to set headers on a coroutine object. Django 5.0 made them check `iscoroutinefunction(func)` and build an `async def` wrapper that awaits the view; the list covers `require_http_methods` and its shortcuts, `condition`, `etag`, `last_modified`, `vary_on_headers`, `vary_on_cookie`, `gzip_page`, `cache_control`, `never_cache`, the CSRF decorators and more. `method_decorator` gained the same support in 5.2. The trap: `condition()` calls `etag_func` and `last_modified_func` synchronously and does not await them. In an async view they run inside the event loop, so a sync ORM query raises `SynchronousOnlyOperation`, and an `async def` callback returns a coroutine instead of a value.
code
python · 17 linesfrom django.http import JsonResponse
from django.views.decorators.http import condition, require_safe
from .models import Product
def product_etag(request, slug):
# Called synchronously even though the view is async:
# a sync ORM query here raises SynchronousOnlyOperation.
return Product.objects.filter(slug=slug).values_list("version", flat=True).first()
@require_safe # fine on async views since 5.0
@condition(etag_func=product_etag) # the callback is the problem, not the decorator
async def product_api(request, slug):
product = await Product.objects.aget(slug=slug)
return JsonResponse({"slug": product.slug, "version": product.version})go deeper
Know that Django's built-in view decorators work on async def views since 5.0.
Explain why a plain wrapper hid the coroutine and what the iscoroutinefunction branch in each decorator changes.
Catch the remaining traps: sync-only custom decorators, condition() callbacks running in the event loop, and method_decorator before 5.2.
Decide which views justify async at all, given that decorators, validators and ORM access must all follow the same calling style.
## Why decorators and async views clashed Django decides how to call a view by asking `iscoroutinefunction(view)`. An `async def` view is awaited in the event loop; a plain function is called directly (or in a thread under ASGI). A classic decorator returns its own wrapper function. If that wrapper is a plain `def`, it hides the coroutine function underneath: 1. Django sees a sync view and calls the wrapper; 2. the wrapper calls the async view, which returns a **coroutine object** instead of a response; 3. a pass-through decorator returns that object, and Django's handler complains that the view "didn't return an HttpResponse object. It returned an unawaited coroutine instead"; 4. a decorator that edits the response, such as `vary_on_cookie`, fails even earlier, because a coroutine has no headers. ## What Django 5.0 changed Each built-in decorator now branches on `iscoroutinefunction(func)`. For a coroutine view it defines an `async def inner(...)` that performs the same checks and `await`s the view, so the decorated result is still a coroutine function. The 5.0 release notes list, among others: - `require_http_methods`, `require_GET`, `require_POST`, `require_safe`; - `condition`, `etag`, `last_modified`, `conditional_page()`; - `vary_on_headers`, `vary_on_cookie`, `gzip_page`; - `cache_control`, `never_cache`, `no_append_slash`; - `csrf_exempt`, `csrf_protect`, `ensure_csrf_cookie`, `requires_csrf_token`; - `sensitive_variables`, `sensitive_post_parameters` and the `xframe_options_*` decorators. Django **5.2** extended the same treatment to `django.utils.decorators.method_decorator`, which now marks its wrapper as a coroutine function when the decorated method is `async def`, so async class-based views can be decorated too. Third-party or home-grown decorators get none of this automatically. A custom decorator that should support both kinds must branch the same way, or be written only for one kind. ## The condition() callback trap `condition()`'s async wrapper awaits the **view**, but its pre-processing step is shared code that calls the two callbacks synchronously: | Callback style | What happens in an async view | |---|---| | plain function with no I/O | fine | | plain function using the ORM | `SynchronousOnlyOperation`, because the sync ORM refuses to run while an event loop is running in the thread | | `async def` function | not awaited; the coroutine object is passed on as if it were the ETag or datetime, and it breaks | Practical options: - keep views that need database-backed validators synchronous, where `condition()` works as designed; - compute validators from data that needs no I/O inside the event loop; - or drop the decorator for that view and handle `If-None-Match` inside the async view with async ORM calls. The environment variable `DJANGO_ALLOW_ASYNC_UNSAFE` disables the ORM guard, but it exists for contexts like notebooks; using it in a server hides a real blocking call in the event loop. ## gzip_page and friends Decorators built from middleware (`gzip_page` is `decorator_from_middleware(GZipMiddleware)`) also gained async support in 5.0. For streaming responses, `GZipMiddleware` compresses an async iterator with an async compressor, so an async streaming view keeps streaming. ## Writing a dual-mode decorator A custom decorator that must support both kinds of views copies Django's pattern: ```python from functools import wraps from inspect import iscoroutinefunction def add_region_header(view): if iscoroutinefunction(view): @wraps(view) async def wrapper(request, *args, **kwargs): response = await view(request, *args, **kwargs) response["X-Region"] = "eu" return response else: @wraps(view) def wrapper(request, *args, **kwargs): response = view(request, *args, **kwargs) response["X-Region"] = "eu" return response return wrapper ``` Both branches share the same logic; only the calling style differs. Duplicated bodies are the price of keeping each path free of thread hops. ## Checklist for async views 1. Confirm the Django version: 5.0 or later for function decorators, 5.2 or later for `method_decorator` on async methods. 2. Audit custom decorators for a sync-only wrapper. 3. Keep `condition()` callbacks synchronous and free of ORM calls in async views. 4. Test with `AsyncClient` so the async path is exercised.
- How would you make a home-grown view decorator work for both sync and async views?Branch on `inspect.iscoroutinefunction(view)`. For a coroutine view define `async def wrapper(request, *args, **kwargs)` that awaits the view; otherwise define a plain `def` wrapper. Wrap both with `functools.wraps`. That mirrors how Django's own decorators have worked since 5.0.
- Why not just set DJANGO_ALLOW_ASYNC_UNSAFE so the ORM call in the callback works?It silences the guard, not the problem: the synchronous query then blocks the event loop and every other request it serves while it waits. The variable is meant for environments such as notebooks. In a server, keep the view sync or move the query to async code.
saying these in an interview costs you the question
- Django adapts any decorator to async views automatically
- condition() awaits async etag callbacks since 5.0
- method_decorator has supported async methods since 5.0
- An unawaited coroutine returned by a view is awaited by the handler
- DJANGO_ALLOW_ASYNC_UNSAFE is the recommended fix for ORM calls in callbacks