Go past the quickstart. Architect DRF for production: routed ViewSets, serializer query optimization, custom throttling, atomic nested writes, and versioning that survives breaking changes.
DRF's tutorial gets you a working API in an afternoon. Production is a different game: serializers that quietly fire 400 queries, throttles that don't hold across workers, nested writes that corrupt data on partial failure, permission checks that leak other tenants' rows, and clients that break the moment you rename a field. This is the deep dive — every layer of DRF that matters once real traffic and real money are on the line.
A ModelViewSet collapses list, create, retrieve, update, partial-update and destroy into one class, and a router generates the URLs and their names. The payoff is consistency across dozens of endpoints; the trap is that clean, terse code hides expensive database work behind a single attribute access.
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
return (Order.objects
.filter(customer=self.request.user)
.select_related("customer", "address")
.prefetch_related("items__product"))
Register it once and every route is wired, named, and documented:
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
router.register("orders", OrderViewSet, basename="order")
urlpatterns = router.urls
When you need a non-CRUD endpoint, resist the urge to bolt on a separate APIView. Add an action so it inherits the same queryset, permissions, throttles and router:
from rest_framework.decorators import action
class OrderViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["post"])
def refund(self, request, pk=None):
order = self.get_object() # honors get_queryset + permissions
order.refund()
return Response({"status": "refunded"})
@action(detail=False)
def summary(self, request):
qs = self.get_queryset()
return Response({"open": qs.filter(status="open").count()})
That gives you POST /orders/{id}/refund/ and GET /orders/summary/ with no extra routing, and both flow through get_object()/get_queryset() so your tenant and ownership scoping still apply. The lesson that runs through this whole tutorial: the more logic you push into the ViewSet's hooks, the fewer places a security or performance bug can hide.
Understanding the order DRF does things tells you exactly which method to override. For every request the dispatcher runs: authentication (who is this?) → throttling (are they over their limit?) → permission checks (may they do this at all?) → the handler (list/create/…) → get_object with object-level permissions when a detail route → serializer validation and save → renderer turning the result into JSON. When something misbehaves, map the symptom to the stage: a 401 is authentication, a 403 is permissions, a 429 is throttling, a slow response is almost always the queryset/serializer stage.
Ninety percent of slow DRF endpoints are slow for one reason: the serializer walks a relation the queryset never fetched, so Django lazily issues a query per row. Two tools cover every case.
select_related follows forward foreign keys and one-to-ones with a SQL JOIN — one query, no extra round trips. Use it for single-valued relations like order.customer and order.address. prefetch_related handles reverse foreign keys and many-to-many with a second query and an in-Python join. Use it for collections like order.items, and chain with the double underscore for nested relations such as items__product.
Without them, a 25-row list page that renders each order's customer and items fires several hundred queries — the classic N+1. With them it is three, flat, regardless of page size. The discipline is absolute: every relation a serializer touches must appear in select_related or prefetch_related. When a prefetch needs filtering, ordering, or its own select_related, reach for a Prefetch object:
from django.db.models import Prefetch
Order.objects.prefetch_related(
Prefetch(
"items",
queryset=OrderItem.objects.filter(active=True)
.select_related("product"),
to_attr="active_items", # serializer reads order.active_items
)
)
Push aggregates into the database with annotate rather than counting in Python, and trim columns you don't serialize with only()/defer() on wide tables. The goal is a fixed, small number of queries that does not grow with the result set.
Beyond prefetching, the serializer itself has sharp edges that surface only under load:
SerializerMethodField without prefetching the data it needs. The method runs once per row, so an unprefetched query there is an N+1 you wrote by hand — and one code review rarely catches, because the query is hidden in a helper.read_only so DRF skips validation and write handling for them entirely.source to flatten instead of a method when you are only reaching one attribute deep: city = serializers.CharField(source="address.city"). It is cheaper and clearer than a method.annotate(item_count=Count("items")) in the queryset, surfaced with an IntegerField(read_only=True) on the serializer — the database aggregates, your worker just reads.to_representation overrides that re-serialize children in a loop; build the structure once.The single highest-leverage move is using a different serializer for list and detail. A list rarely needs every nested object; a slim list serializer can be an order of magnitude cheaper and dramatically smaller on the wire:
class OrderViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
if self.action == "list":
return OrderListSerializer # flat: id, total, status, created
return OrderDetailSerializer # nested: customer, items, address
Field-level rules go in validate_<field>; cross-field rules go in validate. Keep business invariants here, not in the view, so every entry point — API, admin action, import script that reuses the serializer — enforces them identically:
class OrderSerializer(serializers.ModelSerializer):
def validate_quantity(self, value):
if value < 1:
raise serializers.ValidationError("Quantity must be positive.")
return value
def validate(self, data):
if data["ship_date"] < data["order_date"]:
raise serializers.ValidationError(
{"ship_date": "Cannot precede the order date."})
return data
For uniqueness over a combination of fields, use a UniqueTogetherValidator rather than catching an IntegrityError after the fact — the client gets a clean, field-attributed 400 instead of an opaque 500, and you avoid a half-open transaction. Raising with a dict keys the error to a field, which front-ends render next to the right input.
Writable nested serializers are powerful and dangerous. If the parent saves and a child fails, you have half-written data and corrupted state that no client asked for. Wrap the whole operation in a transaction and bulk-insert the children:
from django.db import transaction
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
@transaction.atomic
def create(self, validated_data):
items = validated_data.pop("items")
order = Order.objects.create(**validated_data)
OrderItem.objects.bulk_create(
[OrderItem(order=order, **i) for i in items]
)
return order
bulk_create turns N inserts into one round trip; @transaction.atomic guarantees all-or-nothing so a failure on item 7 rolls back the order and items 1–6 too. Updates are subtler — you must decide and document what a nested write means: replace the whole set, or merge by primary key? Be explicit, because clients depend on it:
@transaction.atomic
def update(self, instance, validated_data):
items = validated_data.pop("items", None)
for field, value in validated_data.items():
setattr(instance, field, value)
instance.save()
if items is not None: # full-replace semantics
instance.items.all().delete()
OrderItem.objects.bulk_create(
[OrderItem(order=instance, **i) for i in items])
return instance
If you instead want merge semantics, match incoming items to existing ones by id, update those, create the new ones, and delete the omitted ones — all still inside the atomic block.
Two clients editing the same resource will silently clobber each other with last-write-wins. For anything where that matters (inventory, balances, bookings) add optimistic locking: carry a version field, require it on write, and reject stale updates:
@transaction.atomic
def update(self, instance, validated_data):
client_version = validated_data.pop("version")
updated = (Order.objects
.filter(pk=instance.pk, version=client_version)
.update(version=client_version + 1, **validated_data))
if not updated:
raise serializers.ValidationError(
"This order changed since you loaded it. Reload and retry.")
instance.refresh_from_db()
return instance
The conditional UPDATE … WHERE version = ? is atomic at the database level, so the loser of a race gets a clean 400 instead of overwriting the winner's change.
Do not hand-roll query-string parsing — it is where injection and 500s creep in. django-filter gives declarative, validated filtering:
class OrderFilter(filters.FilterSet):
min_total = filters.NumberFilter(field_name="total", lookup_expr="gte")
status = filters.ChoiceFilter(choices=Order.STATUS_CHOICES)
created_after = filters.DateFilter(field_name="created_at",
lookup_expr="gte")
class Meta:
model = Order
fields = ["status", "min_total", "created_after"]
class OrderViewSet(viewsets.ModelViewSet):
filter_backends = [DjangoFilterBackend, filters.SearchFilter,
filters.OrderingFilter]
filterset_class = OrderFilter
search_fields = ["reference", "customer__email"]
ordering_fields = ["created_at", "total"]
ordering = ["-created_at"]
The backends validate input for you, so ?min_total=abc becomes a clean 400 rather than a database error, and ordering_fields is an allowlist so a client cannot sort by an unindexed column and table-scan your database.
DRF defaults to PageNumberPagination, which is offset-based under the hood. For large or fast-changing tables, offset pagination is both slow (the database still scans and discards the skipped rows, so page 5,000 is far slower than page 1) and unstable (an insert shifts every subsequent page, so users see duplicates or gaps). Switch to cursor pagination, which is keyset-based — constant time at any depth and stable under concurrent writes:
class OrderCursorPagination(pagination.CursorPagination):
page_size = 50
max_page_size = 200
ordering = "-created_at" # needs a unique, indexed ordering
The tradeoff is no jump-to-page-N, only next/previous. For an API consumed by code rather than a human clicking page numbers, that is almost always the right trade. Make sure the ordering column is indexed, or you have simply moved the slowness.
DRF's throttles store counters in the cache. With multiple gunicorn workers, that cache must be shared (Redis), or each worker counts independently and your effective limit is N times what you configured. Define named scopes and stack a short burst limit with a long sustained limit:
class BurstRateThrottle(throttling.UserRateThrottle):
scope = "burst"
class SustainedRateThrottle(throttling.UserRateThrottle):
scope = "sustained"
REST_FRAMEWORK = {
"DEFAULT_THROTTLE_CLASSES": [
"api.throttles.BurstRateThrottle",
"api.throttles.SustainedRateThrottle",
],
"DEFAULT_THROTTLE_RATES": {"burst": "60/min", "sustained": "1000/day"},
}
Burst stops rapid-fire abuse; sustained stops slow, patient scraping. For expensive endpoints — exports, search, report generation — attach a tighter ScopedRateThrottle to just that action. Always surface a Retry-After header on the 429 so well-behaved clients back off instead of hammering.
Class-level permissions gate the endpoint; object-level permissions gate the row. Enforce both, and remember that has_object_permission only runs when you call get_object() — a raw queryset or a custom action that fetches directly bypasses it:
class IsOwner(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
return obj.customer_id == request.user.id
The defense in depth is to filter by owner in get_queryset and check ownership in has_object_permission. The queryset filter means a forbidden object returns 404 — you don't even reveal that it exists — while the permission check is the backstop for any code path that slips past the filter. In a multi-tenant API this is the difference between a quiet, correct system and a headline breach.
Three common schemes, each with a fit: SessionAuthentication for a browser front-end on the same domain (with CSRF protection); TokenAuthentication for simple server-to-server or mobile clients; and JWT (via djangorestframework-simplejwt) when you need stateless, short-lived access tokens with refresh rotation. Don't mix them carelessly — pick per client type, and never accept a long-lived token where a short access token plus refresh would do. Whatever you choose, terminate TLS everywhere; a bearer token over plain HTTP is a credential on a postcard.
For read-heavy resources, ETags and Last-Modified let clients revalidate cheaply: return the header, and a client that sends If-None-Match gets a tiny 304 Not Modified instead of the full payload. Combine with a short server-side cache (per-view or fragment) keyed by user and query parameters, and you cut both bandwidth and database load. Be careful to vary the cache key by everything that changes the response — user, version, filters — or you will serve one client another's data.
Clients hate guessing at error shapes. Install a custom exception handler that normalizes every error into one envelope, with a machine-readable code and a human message, and make sure unexpected exceptions become a clean 500 body rather than leaking a stack trace:
def api_exception_handler(exc, context):
response = exception_handler(exc, context) # DRF's default first
if response is not None:
response.data = {
"error": {"code": response.status_code, "detail": response.data}
}
return response
REST_FRAMEWORK = {"EXCEPTION_HANDLER": "api.errors.api_exception_handler"}
Pick URLPathVersioning (/api/v2/orders/) — it is explicit, cache-friendly, and trivial to route and log. Branch serializer logic on request.version and keep old versions alive until clients migrate. Never reshape a response in place; you will break every consumer silently and won't find out until the support tickets arrive. Additive changes — new optional fields — are safe within a version; removals, renames, and type changes require a new one. Publish a deprecation timeline and emit a Sunset header on the old version so clients are warned in-band.
Use drf-spectacular to generate an accurate OpenAPI 3 document and Swagger UI straight from your serializers and views. It catches drift between code and docs — the docs literally cannot lie about field names because they are derived from the same serializers — and it gives front-end teams a typed contract they can generate clients from. Annotate ambiguous endpoints with @extend_schema so request/response examples stay honest, and check the generated schema into CI so an accidental breaking change shows up as a diff.
Use APIClient and assert on status code, payload shape, and query count — a regression that doubles your queries should fail a test, not page you at 2am:
from rest_framework.test import APIClient
def test_order_list_query_budget(django_assert_max_num_queries):
client = APIClient(); client.force_authenticate(user)
with django_assert_max_num_queries(4):
resp = client.get("/api/v1/orders/")
assert resp.status_code == 200
assert set(resp.json()["results"][0]) == {"id", "total", "status", "created"}
That query-count assertion is your permanent N+1 net: the day someone drops a prefetch_related, CI goes red instead of customers noticing the slowdown.
SerializerMethodField — the N+1 you cannot see in code review.get_object() — the silent authorization hole.page_size — a client requests 100,000 rows and takes a worker down. Cap it.Clients that sync often need to create or update many rows in one call. Don't make them loop one POST per item — that is N transactions, N round trips, and a partial-failure nightmare. Accept a list and process it atomically:
class BulkOrderSerializer(serializers.ListSerializer):
@transaction.atomic
def create(self, validated_data):
return Order.objects.bulk_create(
[Order(**row) for row in validated_data])
class OrderSerializer(serializers.ModelSerializer):
class Meta:
model = Order
list_serializer_class = BulkOrderSerializer
# in the view: serializer = OrderSerializer(data=request.data, many=True)
Decide your failure policy up front and document it: all-or-nothing (one bad row rejects the batch — simplest and safest) versus partial success (apply the good rows, return a per-row report of failures). All-or-nothing is the right default; only build partial-success when a client genuinely needs it, because the response contract is much more complex.
DRF negotiates formats through parsers and renderers. For JSON-only APIs the defaults are fine, but file uploads need a multipart parser, and you should still validate aggressively — size, content type, and extension — before touching storage:
class AvatarUploadView(APIView):
parser_classes = [MultiPartParser]
def post(self, request):
f = request.FILES["avatar"]
if f.size > 5 * 1024 * 1024:
raise ValidationError("Max 5 MB.")
if f.content_type not in {"image/png", "image/jpeg"}:
raise ValidationError("PNG or JPEG only.")
...
Never trust the client-supplied filename or content type as the last word — re-derive the type from the bytes where it matters, and store under a server-generated path so an attacker can't traverse directories with a crafted filename. For large files, prefer presigned direct-to-storage uploads so the file never streams through your application workers at all.
Networks fail after the server committed but before the client got the response, so clients retry — and on a non-idempotent POST that means a double charge or duplicate order. Borrow the pattern every payment API uses: the client sends a unique Idempotency-Key header, and you guarantee the operation runs at most once per key:
def create(self, request, *args, **kwargs):
key = request.headers.get("Idempotency-Key")
if key:
cached = cache.get(f"idem:{key}")
if cached is not None:
return Response(cached, status=201) # replay stored result
response = super().create(request, *args, **kwargs)
if key:
cache.set(f"idem:{key}", response.data, timeout=86400)
return response
The retry returns the original result instead of creating a second resource. Store the key alongside the created object's ID so even a cache eviction can't let a duplicate through — fall back to a uniqueness constraint on the key column for true safety.
Retry-After header.get_object().max_page_size.Production DRF is mostly query discipline and failure handling. Push logic into ViewSet hooks and serializers so there are fewer places for bugs to hide; prefetch every relation your serializers touch and assert a query budget in tests; slim down list serializers; make nested writes atomic with bulk inserts and add optimistic locking where races matter; throttle through a shared Redis cache with stacked burst and sustained limits; enforce permissions at both the queryset and object level; paginate with cursors on hot tables; normalize errors; and version explicitly with deprecation headers. Generate your OpenAPI schema from the code so the docs can't drift. Get those right and DRF scales cleanly from weekend prototype to millions of requests a day without rewrites.