Django and DRF: the request path
Article.
Django REST Framework sits on Django’s view layer and adds serializers, class-based API views and viewsets, routers, content negotiation, and pluggable authentication, permission and throttle classes. The senior questions are about where each of those runs, because the order explains the most common security bug in DRF code, and about what Django itself does and does not guarantee underneath: transactions and async.
Walk a DRF request from the server to the response. Where do
authentication, permissions, throttles and object permissions run? Every
trace below is real output from Django 6.1.2 and DRF 3.18.3, with tracing
classes plugged into a ModelViewSet.
What DRF adds
| Concern | Plain Django | DRF |
|---|---|---|
| (De)serialization | JsonResponse, manual |
Serializer, validation |
| Views | View |
APIView, ModelViewSet |
| URLs | path() per view |
routers, @action |
| Auth and access | decorators | auth, permission, throttle classes |
| Formats | one response type | parsers and renderers by Accept |
| Lists | Paginator |
pagination and filter backends |
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.select_related("author")
serializer_class = BookSerializer
router = DefaultRouter()
router.register("books", BookViewSet) # list, create, retrieve, update, destroyThe request path
Django’s half
The server calls Django’s WSGI or ASGI handler. request_started fires (old
database connections are closed according to CONN_MAX_AGE), the middleware
chain runs, the URL resolver picks the view, and the view runs.
If ATOMIC_REQUESTS is on, only the view is wrapped in a transaction, not the
middleware and not a streaming body. DRF views are csrf_exempt;
SessionAuthentication enforces CSRF itself
(Django middleware).
DRF’s half, traced
GET /api/books/<bob's book>/ as alice:
content negotiation, authentication, has_permission, throttle,
handler: retrieve, has_object_permission -> 403
GET /api/books/ as alice:
content negotiation, authentication, has_permission, throttle,
handler: list -> 200, both booksAPIView.dispatch wraps the request, then initial() runs, in this order:
- Content negotiation and versioning: picks the renderer before anything else, so even errors come back in the negotiated format.
- Authentication: lazy, triggered by the first access to
request.user; DRF touches it here. - Permissions:
has_permissionof every class. - Throttles: after permissions, so rejected requests are not counted.
- The handler (
list,retrieve, …). The body is parsed lazily, when the handler first readsrequest.data. - Object permissions:
has_object_permissionruns only when the handler callsget_object(), inside the handler. - The
Responseis rendered; exceptions anywhere go throughEXCEPTION_HANDLER.
The list leak
The second trace is the bug. IsOwner.has_object_permission blocked alice
from bob’s book on retrieve, but list never calls get_object(), so the
same permission class let her list every book, including bob’s. Object
permissions do not filter querysets. Scope the queryset itself:
def get_queryset(self):
return Book.objects.filter(owner=self.request.user)With the queryset scoped, retrieve of someone else’s book returns 404
rather than 403, which also stops it from revealing that the id exists.
401 or 403
SessionAuthentication first TokenAuthentication first
bad token 403 401
no credentials 403 401DRF returns 401 only when it can send a WWW-Authenticate header, and it
asks only the first class in authentication_classes for that header.
SessionAuthentication has none, so with it first, every authentication
failure becomes 403. API clients that refresh tokens on 401 never see one.
Put the token or JWT class first.
Transactions: what atomic does not do
@transaction.atomic
def withdraw(account_id, amount):
a = Account.objects.get(pk=account_id)
a.balance -= amount # computed in Python
a.save()Five concurrent withdrawals of 10 from a balance of 100, PostgreSQL 18:
| Code | Final balance (want 50) |
|---|---|
atomic, read-modify-write |
90 |
atomic + select_for_update() |
50 |
update(balance=F("balance") - 10) |
50 |
atomic makes the block all-or-nothing; it does not stop two blocks from
reading the same value at READ COMMITTED. select_for_update() locks the row
until commit; F() makes the database do the arithmetic. Other rules worth
knowing (Transactions and ACID):
- Nested
atomicis a savepoint. Catching anIntegrityErrorinside anatomicblock without a nestedatomicaround the failing statement leaves the transaction broken (TransactionManagementError). transaction.on_commit(fn)runsfnonly if the transaction commits: the place to enqueue a Celery task that reads the new row (Celery 5.4 addsdelay_on_commit).select_for_update()outside a transaction raises, and it acceptsnowait,skip_lockedandof=.
Async: what Django 6.1 does not do
Django runs async views under ASGI, and the ORM has a-prefixed methods
(aget, acreate, async for). Their limits:
- No async transactions.
transaction.aatomicdoes not exist in 6.1; the documentation says transactions do not work in async mode. Put the transactional block in a sync function and call it withsync_to_async. - The async ORM still uses a sync driver in a thread, so it does not multiply database concurrency.
- Lazy relation access in an async view raises
SynchronousOnlyOperationinstead of running a query (N+1 in Django: select_related, prefetch_related, fetch modes). - DRF is synchronous:
APIView.dispatchis sync in 3.18; async views need a third-party package (adrf). Under ASGI a sync view costs a thread per request. - Persistent connections: disable
CONN_MAX_AGEunder ASGI and use the native psycopg pool (OPTIONS={"pool": True}, Django 5.1+) (Async Django, Channels, and Async ORM).
What serialization costs
10,000 rows, three fields:
ModelSerializer(many=True) 0.049 s
plain Serializer 0.036 s
queryset.values() 0.010 sEach instance goes through every field’s to_representation; values()
returns dicts straight from the database. For large exports and hot list
endpoints, skip serializer instances, and in either case remove N+1 queries
first, which usually cost far more than serialization
(DRF Performance and the N+1 Problem).
Where it breaks in production
Object permissions trusted to filter lists
The trace above: retrieve is protected and list leaks every row. Every
viewset with object permissions needs a scoped get_queryset; a test that
lists as a second user catches it.
403 where the client expects 401
SessionAuthentication first in DEFAULT_AUTHENTICATION_CLASSES turns
expired tokens into 403, and mobile clients that refresh on 401 log users
out instead. Order the classes deliberately.
Lost updates inside atomic
Balances, stock counts and counters updated with read-modify-write in
Python lose concurrent updates even inside atomic: 90 instead of 50 above.
Use F() or select_for_update().
Tasks enqueued before commit
A view creates an order and calls send_receipt.delay(order.id) inside the
transaction; the worker runs before the commit and finds no order. Use
transaction.on_commit or delay_on_commit.
The trade not taken
DRF trades speed for a complete, declarative toolkit: the browsable API, pluggable auth, permissions and throttles, and serializers that validate and document. Django Ninja and FastAPI use Pydantic, are faster per request and async-native, but leave more of that toolkit to you.
For a Django codebase, DRF remains the default; for a new async API without Django’s admin and ORM, FastAPI is the usual choice (FastAPI).
The rest of this folder
| # | File | Topic |
|---|---|---|
| 02 | DRF Serializers | Serializer, ModelSerializer, fields |
| 03 | DRF Validation | field, object and unique validation |
| 04 | DRF Views: APIView, GenericAPIView, ViewSet | APIView to ViewSet |
| 05 | DRF Routers and Custom Actions | routers, @action |
| 06 | DRF Permissions | permission classes |
| 07 | DRF Authentication | Session, Token, JWT |
| 08 | DRF Throttling | throttles |
| 09 | DRF Pagination | page, limit-offset, cursor |
| 10 | DRF Filtering, Searching, Ordering | filters, search, ordering |
| 11 | DRF Nested Writable Serializers | nested writes |
| 12 | DRF Performance and the N+1 Problem | N+1 in serializers |
| 13 | Testing DRF APIs | APIClient |
| 14 | DRF Exception Handling | the exception handler |
| 15 | DRF Versioning | API versioning |
| 16 | DRF Schema and OpenAPI | drf-spectacular |
Related
Interview angle 5
- “Walk a DRF request through.” - Django’s handler, middleware, URL
resolution; then DRF’s
dispatch: content negotiation, authentication,has_permission, throttles, the handler, andhas_object_permissiononly if the handler callsget_object(); then rendering.- Follow-up: “Why could alice list bob’s books when
IsOwnerblocked retrieving one?” -listnever callsget_object(). Object permissions do not filter querysets; scopeget_queryset.
- Follow-up: “Why could alice list bob’s books when
- “Why does a client with a bad token get 403 instead of 401?” - DRF
takes the
WWW-Authenticateheader from the first authentication class; withSessionAuthenticationfirst there is none, so 403. Measured: 403 session-first, 401 token-first. - “Does
transaction.atomicprevent lost updates?” - no: five concurrent withdrawals left 90 instead of 50.select_for_update()orF()did.- Follow-up: “Then what is
atomicfor?” - all-or-nothing: if the second write fails, the first is rolled back. Isolation from other transactions is a separate question.
- Follow-up: “Then what is
- “Can you use
transaction.atomicin an async view?” - not directly; Django 6.1 has no async transactions (aatomicdoes not exist). Wrap the block in a sync function and call it withsync_to_async. - “Where does DRF hurt at scale?” - N+1 queries in serializers first,
then serializer overhead itself: 0.049 s for 10,000 rows against 0.010 s
for
values(). Bypass serializers for exports.