skip to content

Why does a Django Ninja async def search operation that returns Product.objects.filter(...) with response=list[ProductOut] fail, and how do you write it correctly?

level: seniorimportance: must knowfreq 45%

answer

  1. the view is async, serialisation is not
  2. lazy until something iterates
  3. Django's async-unsafe guard
  4. evaluate and load relations in the view

basics

~20 s

Ninja awaits the async view, then serialises the result synchronously; iterating the lazy queryset there hits the database from the event loop, and Django raises SynchronousOnlyOperation. Evaluate it in the view with async iteration and select_related any relations the schema reads.

solid answer

~40 s

Declaring the operation `async def` makes Ninja register an async operation: it awaits the view, but the step that validates the return value against `list[ProductOut]` is ordinary synchronous code. A queryset is lazy, so the first database access happens during that step, inside the event loop's thread, and Django's async-unsafe guard raises `SynchronousOnlyOperation` ("You cannot call this from an async context - use a thread or sync_to_async."), which the client sees as a 500. The fix is to finish all database work inside the view: `[p async for p in qs[:20]]`, `await Product.objects.aget(...)`, `await qs.acount()`, and `select_related("category")` so a schema field like `category.name` does not trigger a lazy load during serialisation. `@paginate` on an async operation evaluates the queryset asynchronously itself. Async only pays off under an ASGI server for I/O-bound waits.

code

python · 29 lines
python
from ninja import NinjaAPI, Schema

api = NinjaAPI()


class ProductOut(Schema):
    id: int
    name: str
    category_name: str | None = None

    @staticmethod
    def resolve_category_name(obj):
        return obj.category.name if obj.category else None


# Broken: the queryset is evaluated during synchronous serialisation.
# @api.get("/search", response=list[ProductOut])
# async def search(request, q: str):
#     return Product.objects.filter(name__icontains=q)


@api.get("/search", response=list[ProductOut])
async def search(request, q: str, limit: int = 20):
    qs = (
        Product.objects.filter(name__icontains=q, is_active=True)
        .select_related("category")
        .order_by("name")[:limit]
    )
    return [p async for p in qs]

go deeper

for a junior

Recall that async def makes a Django Ninja operation async and that ORM calls inside it need the async API.

for a middle

Explain why a lazy queryset or an unloaded relation runs its query in the synchronous serialisation step, and which async ORM calls replace the sync ones.

for a senior

Diagnose SynchronousOnlyOperation from the traceback, finish database work in the view with related data loaded, and know when async is worth it under ASGI.

for a principal

Decide which endpoints justify async - those waiting on slow I/O - and keep the rest synchronous rather than converting a codebase wholesale.

## How Django Ninja runs an async operation In **Django Ninja**, an operation becomes asynchronous simply by being declared `async def`. At registration Ninja creates an **async operation** and makes the path's Django view a coroutine function; a synchronous operation sharing the same path is then run through `sync_to_async`. Each request goes through these steps: 1. authentication and throttle checks (awaited where needed); 2. request parameter validation; 3. `await` the view function; 4. **synchronously** validate the return value against the `response=` schema and render JSON. Step 4 is the one that matters. It is plain synchronous code that runs in the event loop's thread, straight after the `await`. ## Why returning a queryset fails A Django `QuerySet` is **lazy**: building `Product.objects.filter(name__icontains=q)` runs no SQL. The query runs when something iterates it - and in this operation that something is step 4, which converts the queryset to a list while filling `list[ProductOut]`. Django's database layer checks whether it is being called from a running event loop and, if so, raises **`SynchronousOnlyOperation`** with the message "You cannot call this from an async context - use a thread or sync_to_async." Ninja's generic exception handling turns that into a 500 (with a traceback when `DEBUG` is on). The same failure appears without any queryset in the return value: - a schema field or resolver reading `obj.category.name` when `category` was not loaded; - a schema field backed by a reverse relation or many-to-many manager, which Ninja converts with `.all()`; - a model property that runs a query. ## Writing it correctly Finish every database access **inside the view**, with the ORM's async API, and load what the schema will read: | need | async form | |---|---| | a list of rows | `[p async for p in qs[:20]]` | | one row | `await Product.objects.aget(pk=pk)` | | a count or existence check | `await qs.acount()`, `await qs.aexists()` | | first row | `await qs.afirst()` | | code with no async variant | `await sync_to_async(func)(...)` | Add `select_related("category")` (or a prefetch) to the queryset so serialisation reads attributes already in memory. With `@paginate`, the built-in paginators evaluate a returned queryset through their async path, so a paginated async operation may return the queryset - but the relation-loading rule still applies. Older Ninja guides predate Django's async ORM and wrap every call in `sync_to_async`; that still works, but the `a`-prefixed methods and async iteration are the current idiom. ## Diagnosing it from the traceback The traceback tells you where the synchronous query came from: 1. The exception type is `SynchronousOnlyOperation`, raised from Django's database layer. 2. If the frames above it are inside your view, the view itself calls a synchronous ORM method such as `get()`, `count()` or `list(qs)`. 3. If the frames run through Ninja's response handling and schema validation, the query is triggered while serialising - a returned queryset, an unloaded relation, a manager field or a resolver. 4. The last frame in your own code names the attribute or resolver to fix. `ninja.testing.TestAsyncClient` calls async operations from tests, so a regression test that requests the search endpoint reproduces the error without a running server. Asserting on the number of queries alongside the status code also catches a missing `select_related` early. ## When async is worth it - **Under an ASGI server**, an async operation releases the worker while it waits on I/O, so many slow searches can overlap in one process. - **The work must actually await something**: an external search service through an async client, or database calls through the async ORM. CPU-heavy work and synchronous calls block the event loop for every request it is serving. - **Under WSGI**, async operations still run, but each request gets its own short-lived event loop, so there is no concurrency gain. ## Mistakes interviewers probe - Setting `DJANGO_ALLOW_ASYNC_UNSAFE` to silence the error: it disables the guard and lets blocking database calls stall the event loop. - Calling `list(qs)` in an async view - still a synchronous query in the event loop. - Converting a working sync operation to `async def` with no awaits inside, which adds overhead and no concurrency. - Forgetting that resolvers and nested schemas run during synchronous serialisation.

  • Why does a Django Ninja async operation still fail after evaluating the queryset with async iteration?
    Something read during serialisation still queries: a foreign key that was not loaded, a many-to-many or reverse-relation field Ninja converts with `.all()`, or a resolver calling the ORM. Those run in the synchronous serialisation step inside the event loop. Load them in the view with `select_related` or a prefetch, or compute the values there.
  • Can one Django Ninja API mix sync and async operations, even on the same path?
    Yes. Each operation is registered as sync or async from its function. When any operation on a path is async, Ninja makes that path's Django view async and runs the synchronous operations through `sync_to_async`, so a sync GET and an async POST can share a path.

An async operation is a waiter who can serve many tables while each kitchen order cooks, but who must plate everything before walking back: if the plate still needs cooking when it reaches the pass, the waiter is stuck at the stove and every other table waits.

saying these in an interview costs you the question

  • Ninja serialises the response asynchronously, so returning a queryset is fine.
  • Setting DJANGO_ALLOW_ASYNC_UNSAFE is an acceptable production fix.
  • list(queryset) inside an async view is safe because the view is async.
  • Declaring an operation async makes it faster even with no awaits inside.
  • Paginated async operations must return a list instead of a queryset.