skip to content

In Django, what does await Notification.objects.aget(pk=pk) actually do underneath, and what does that mean for performance?

level: middleimportance: should knowfreq 40%

answer

  1. read the method body
  2. the driver is still synchronous
  3. same save, same signals
  4. a thread per call

basics

~10 s

It awaits sync_to_async(self.get): the ordinary synchronous get() runs on a worker thread with a synchronous database driver. The async ORM frees the event loop, but each query still occupies a thread and a connection.

solid answer

~40 s

The async ORM is an **interface**, not an async database stack. In current Django `aget()` is literally `await sync_to_async(self.get)(...)`, and the same pattern holds for `acount()`, `acreate()`, `asave()`, `aadd()` and the rest. The 4.1 release notes say the underlying database operations remain synchronous and the interface encapsulates the `sync_to_async()` calls for you. Consequences: the event loop stays free while the query runs, but the query runs on a thread with the usual driver and per-thread connection; overridden `save()` methods and `pre_save`/`post_save` receivers still run, synchronously, in that thread; and a loop of a-calls is a loop of thread handoffs. The async ORM makes Django code correct in async views — it does not make queries faster.

code

python · 21 lines
python
# Paraphrased shape of the implementation in Django 6.1
from asgiref.sync import sync_to_async


# django/db/models/query.py
class QuerySet:
    async def aget(self, *args, **kwargs):
        return await sync_to_async(self.get)(*args, **kwargs)

    async def acount(self):
        return await sync_to_async(self.count)()


# django/db/models/base.py
class Model:
    async def asave(self, *, force_insert=False, force_update=False,
                    using=None, update_fields=None):
        return await sync_to_async(self.save)(
            force_insert=force_insert, force_update=force_update,
            using=using, update_fields=update_fields,
        )

go deeper

for a junior

Know that the a-prefixed methods exist so async views can query safely, not because the database got faster.

for a middle

Explain that each a-method wraps its sync twin with sync_to_async, so the sync driver, save() overrides and signals all still run.

for a senior

Reason about throughput: handoffs per call, serialized thread-sensitive calls, pool-bound capacity, and why gather over a-calls is not parallel.

for a principal

Set expectations for an async migration: the ORM interface buys correctness today and future-proofing, while database throughput still comes from query design.

## What the source says Open `django/db/models/query.py` and the async methods are short: - `aget()` returns `await sync_to_async(self.get)(*args, **kwargs)`; - `acount()`, `aexists()`, `acreate()`, `aget_or_create()`, `aupdate()`, `adelete()` follow the same shape; - `Model.asave()` in `base.py` awaits `sync_to_async(self.save)` with the same keyword arguments; - related-manager methods such as `aadd()` and `acreate()` wrap their sync counterparts the same way. The 4.1 release notes, which introduced the interface, are explicit: the underlying database operations **remain synchronous**, and the new interface "encapsulates the necessary `sync_to_async()` operations for you", so code written against it can benefit later if async support is pushed down into the SQL compiler and async drivers. ## What that implies | Question | Answer in current Django | |---|---| | Does the query block the event loop? | No — it runs on a worker thread while the loop serves other coroutines | | Does it use an async database driver? | No — the same synchronous driver and per-thread connection | | Does an overridden `save()` run on `asave()`? | Yes — `asave()` calls `self.save()` | | Do `pre_save` / `post_save` receivers fire? | Yes, synchronously, inside that thread | | Is it faster than a sync view doing the same query? | Not per query; the SQL and driver are identical, plus a small handoff | | Can two a-calls in one request overlap? | Generally no — they are thread-sensitive, so they run one after another on the request's worker | ## Performance consequences 1. **Correctness, not speed.** The async ORM lets an async view query the database without tripping the async-safety guard or stalling the loop. Each query costs what it always did. 2. **Handoffs add up.** Every a-call is one crossing to a thread and back. In a loop over hundreds of rows those crossings — and the queries — multiply; fetch in bulk instead. 3. **Gathering a-calls does not parallelise them.** Wrapping five `acount()` calls in `asyncio.gather` looks concurrent, but thread-sensitive calls in one request serialize on its worker. Overlap comes from non-database awaits running beside them. 4. **Connections are still per thread.** Capacity is bounded by the database connection pool, not by how many coroutines you can create. ## Why use it anyway - It is the **supported** way to query from async code, and it keeps views free of hand-written adapter calls. - It gives the right shape for the future: if Django later runs queries natively async, code written with `aget()` benefits without changes. - It combines naturally with the parts that *are* worth doing async — awaiting several HTTP calls, streaming, long-polling — in the same view. ## Where the async ORM still helps performance The async ORM does not speed up a query, but it can speed up a **request**: - an async view can `await` a database lookup and, alongside it, awaited HTTP calls or cache reads that genuinely run concurrently on the loop; - under ASGI, a request waiting on those network calls holds a coroutine rather than a thread, so the process sustains more concurrent requests; - database-bound time is unchanged, so the gain is proportional to how much of the request is spent **outside** the database. The practical sizing rule follows: pool size and query count bound database throughput, whatever style the view is written in. ## What an interviewer is checking They want to hear that you have read past the name. A candidate who says "`aget()` uses an async driver so it is faster" has the model backwards; a strong answer names the `sync_to_async` wrapper, the synchronous driver, the per-thread connection, and the practical rule that async ORM calls are for correctness inside async code, while throughput still comes from fewer and better queries.

  • If you override save() on a Django model, does asave() run your override?
    Yes. `Model.asave()` awaits `sync_to_async(self.save)`, so your overridden `save()` runs, on a worker thread, together with the `pre_save` and `post_save` signals it sends. Overriding only `asave()` would not affect sync callers, so put custom save logic in `save()`.
  • Will asyncio.gather over five acount() calls in one Django request run the counts in parallel?
    No. Each `acount()` is a thread-sensitive `sync_to_async` call, and within one request those calls serialize on the request's worker thread. The gather returns when all five have run one after another; to save time, combine them into one aggregate query.

A receptionist who takes your request and hands it to the same back-office clerk as before: you can wait in the lobby instead of blocking the desk, but the clerk works at the same speed.

saying these in an interview costs you the question

  • aget() uses an async database driver, so it is faster than get().
  • asave() skips overridden save() methods and model signals.
  • Gathering several a-prefixed ORM calls runs the queries in parallel.
  • The async ORM removes the need for database connections per thread.
  • Switching views to a-prefixed calls reduces database load.