Django and DRF: the request path

Updated 5 interview angles 7 min read source
On this page10
  1. What DRF adds
  2. The request path
  3. Transactions: what atomic does not do
  4. Async: what Django 6.1 does not do
  5. What serialization costs
  6. Where it breaks in production
  7. The trade not taken
  8. The rest of this folder
  9. Related
  10. Interview angle

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
python
class BookViewSet(viewsets.ModelViewSet):
    queryset = Book.objects.select_related("author")
    serializer_class = BookSerializer

router = DefaultRouter()
router.register("books", BookViewSet)       # list, create, retrieve, update, destroy

The 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

text
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 books

APIView.dispatch wraps the request, then initial() runs, in this order:

  1. Content negotiation and versioning: picks the renderer before anything else, so even errors come back in the negotiated format.
  2. Authentication: lazy, triggered by the first access to request.user; DRF touches it here.
  3. Permissions: has_permission of every class.
  4. Throttles: after permissions, so rejected requests are not counted.
  5. The handler (list, retrieve, …). The body is parsed lazily, when the handler first reads request.data.
  6. Object permissions: has_object_permission runs only when the handler calls get_object(), inside the handler.
  7. The Response is rendered; exceptions anywhere go through EXCEPTION_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:

python
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

text
                          SessionAuthentication first   TokenAuthentication first
bad token                 403                           401
no credentials            403                           401

DRF 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

python
@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 atomic is a savepoint. Catching an IntegrityError inside an atomic block without a nested atomic around the failing statement leaves the transaction broken (TransactionManagementError).
  • transaction.on_commit(fn) runs fn only if the transaction commits: the place to enqueue a Celery task that reads the new row (Celery 5.4 adds delay_on_commit).
  • select_for_update() outside a transaction raises, and it accepts nowait, skip_locked and of=.

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.aatomic does 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 with sync_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 SynchronousOnlyOperation instead of running a query (N+1 in Django: select_related, prefetch_related, fetch modes).
  • DRF is synchronous: APIView.dispatch is 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_AGE under ASGI and use the native psycopg pool (OPTIONS={"pool": True}, Django 5.1+) (Async Django, Channels, and Async ORM).

What serialization costs

text
10,000 rows, three fields:
  ModelSerializer(many=True)   0.049 s
  plain Serializer             0.036 s
  queryset.values()            0.010 s

Each 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

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, and has_object_permission only if the handler calls get_object(); then rendering.
    • Follow-up: “Why could alice list bob’s books when IsOwner blocked retrieving one?” - list never calls get_object(). Object permissions do not filter querysets; scope get_queryset.
  • “Why does a client with a bad token get 403 instead of 401?” - DRF takes the WWW-Authenticate header from the first authentication class; with SessionAuthentication first there is none, so 403. Measured: 403 session-first, 401 token-first.
  • “Does transaction.atomic prevent lost updates?” - no: five concurrent withdrawals left 90 instead of 50. select_for_update() or F() did.
    • Follow-up: “Then what is atomic for?” - all-or-nothing: if the second write fails, the first is rolled back. Isolation from other transactions is a separate question.
  • “Can you use transaction.atomic in an async view?” - not directly; Django 6.1 has no async transactions (aatomic does not exist). Wrap the block in a sync function and call it with sync_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.