Django Rest Framework (DRF) Complete Learning Guide
Django REST Framework (DRF) — Complete Learning Notes
Format: Chapter-by-chapter. Every chapter has: Theory → Internal Working → Diagrams → Commands → Code Examples → Real-World Examples → Production Examples → Common Mistakes → Troubleshooting → Security Notes → Interview Questions → Hands-on Lab → Exercises → Quiz.
Table of Contents
- Introduction & Internal Architecture
- Serializers
- ModelSerializers & Validation
- Views — APIView & Generic Views
- ViewSets & Routers
- Authentication
- Permissions
- Throttling & Rate Limiting
- Pagination
- Filtering, Searching & Ordering
- Testing DRF APIs
- Production: Deployment, Security, Performance & Docs
Chapter 1: Introduction & Internal Architecture
Theory
Django REST Framework is a toolkit built on top of Django that turns Django's HTML-oriented request/response cycle into a content-negotiated, API-oriented one. It doesn't replace Django's URL routing, middleware, or ORM — it wraps Django's HttpRequest/HttpResponse with its own Request/Response objects and adds:
- Serialization — converting complex data (querysets, model instances) to/from JSON, XML, etc.
- Parsers/Renderers — content negotiation (deciding input/output format).
- Authentication/Permissions/Throttling — pluggable request gatekeeping.
- Generic Views/ViewSets — reusable CRUD patterns.
- Browsable API — an HTML UI for exploring your API.
DRF is essentially a set of mixins and wrapper classes around Django's class-based views (CBVs). If you understand Django CBVs, DRF is "CBVs + content negotiation + serialization".
Internal Working
When a request hits a DRF view:
- Django's URL resolver calls
View.as_view()— DRF'sAPIView.as_view()wraps this and disables Django's CSRF check for session auth edge cases, deferring CSRF enforcement until after authentication. APIView.dispatch()runs (this is the heart of DRF):- Builds a DRF
Requestobject wrapping Django'sHttpRequest(lazy parsing of body viarequest.data). - Calls
self.initial(request)→ runs content negotiation, authentication, permission checks, throttle checks. - Looks up the handler method (
get,post, etc.) matchingrequest.method.lower(). - Calls the handler, catches exceptions via
self.handle_exception(), converts them to DRFResponseobjects with proper status codes. - Calls
self.finalize_response()which selects a renderer via content negotiation and renders the response body lazily (only when accessed, e.g., by Django'sSimpleTemplateResponsemachinery).
- Builds a DRF
Client Request
│
▼
Django URL Resolver
│
▼
APIView.as_view() → APIView.dispatch()
│
├── request = Request(django_request) (lazy .data / .query_params)
│
├── self.initial(request)
│ ├── perform_content_negotiation()
│ ├── perform_authentication() → request.user, request.auth
│ ├── check_permissions() → PermissionDenied / NotAuthenticated
│ └── check_throttles() → Throttled
│
├── handler = getattr(self, request.method.lower())
├── response = handler(request, *args, **kwargs)
│
└── self.finalize_response(request, response)
└── renderer negotiated → Response rendered (JSON/HTML/etc.)
Diagrams
┌─────────────────────────────┐
│ DRF Request Life │
└─────────────────────────────┘
HTTP Request
│
▼
┌───────────┐ ┌────────────┐ ┌─────────────┐ ┌────────────┐
│ Parsers │ → │ Auth │ → │ Permissions │ → │ Throttling │
│(JSON/Form) │ │ (Session, │ │ (IsAuth, │ │ (Rate │
│ │ │ Token,JWT) │ │ IsAdmin..) │ │ limiting) │
└───────────┘ └────────────┘ └─────────────┘ └────────────┘
│
▼
┌─────────────────┐
│ View / ViewSet │
│ business logic │
└─────────────────┘
│
▼
┌─────────────────┐
│ Serializer │
│ (to_representation)│
└─────────────────┘
│
▼
┌─────────────────┐
│ Renderer │
│ (JSON/HTML/XML) │
└─────────────────┘
│
▼
HTTP Response
Commands
# Install
pip install djangorestframework
# Create project & app
django-admin startproject config .
python manage.py startapp api
# Add to INSTALLED_APPS in settings.py: 'rest_framework'
# Run server
python manage.py runserver
# Generate migrations after model changes
python manage.py makemigrations
python manage.py migrate
# Open Django shell to test serializers/queries interactively
python manage.py shell
Code Examples
# settings.py
INSTALLED_APPS = [
...
'rest_framework',
'api',
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticatedOrReadOnly',
],
}
# api/views.py — the minimal APIView
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
class PingView(APIView):
def get(self, request):
return Response({"message": "pong"}, status=status.HTTP_200_OK)
# config/urls.py
from django.urls import path
from api.views import PingView
urlpatterns = [
path('api/ping/', PingView.as_view(), name='ping'),
]
Real-World Examples
- A mobile banking app backend exposes
/api/accounts/<id>/transactions/returning JSON — built as a DRFAPIViewsubclass with GET-only support and pagination. - A SaaS dashboard's frontend (React/Vue) consumes
/api/v1/metrics/— DRF returns JSON while the same view, browsed in a web browser during development, renders as a browsable HTML API for manual testing.
Production Examples
- Companies like Mozilla, Red Hat, and Instagram (early years) have used Django-based stacks; DRF is the de-facto standard for Django JSON APIs in production because of its content negotiation and pluggable auth working seamlessly with existing Django session-based admin panels.
- A typical production stack: Nginx → Gunicorn/uWSGI → Django/DRF → PostgreSQL, with DRF's
DEFAULT_RENDERER_CLASSESrestricted toJSONRendereronly (browsable API disabled) for performance and security.
Common Mistakes
- Treating
request.datalike Django'srequest.POST—request.datahandles JSON, form-data, and multipart uniformly;request.POSTonly handles form-encoded data. - Forgetting
rest_frameworkinINSTALLED_APPS→ModuleNotFoundErroror missing static files for the browsable API. - Mixing Django's
HttpResponsewith DRF views instead ofrest_framework.response.Response, which breaks content negotiation. - Not setting
DEFAULT_RENDERER_CLASSESexplicitly in production, leaving the browsable API (and its CSS/JS) exposed publicly.
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
TemplateDoesNotExist: rest_framework/api.html |
Static/template app not loaded | Ensure rest_framework is in INSTALLED_APPS before collectstatic |
AttributeError: 'WSGIRequest' object has no attribute 'data' |
Using Django's raw request instead of DRF's | Make sure view extends APIView, not plain Django View |
| 415 Unsupported Media Type | Client sends unsupported Content-Type |
Add appropriate parser or fix client header |
| 406 Not Acceptable | Client Accept header doesn't match any renderer |
Add renderer or fix client Accept header |
Security Notes
- Never leave
BrowsableAPIRendererenabled in production for authenticated/sensitive endpoints — it exposes forms and schema info. - Always pair
SessionAuthenticationwith Django's CSRF protection for browser clients; it's safe by default only when CSRF middleware is active. - Avoid
DEBUG=Truein production — DRF's exception handler will leak stack traces via API responses otherwise.
Interview Questions
- What problem does DRF solve on top of vanilla Django?
- Walk through what happens internally when a request hits an
APIView. - What's the difference between
request.POSTandrequest.data? - How does DRF perform content negotiation?
- Why might you disable the browsable API in production?
Hands-on Lab
Build a minimal DRF project with one APIView returning a static JSON payload, confirm it renders both as JSON (curl -H "Accept: application/json") and as browsable HTML in a browser.
Exercises
- Create a
HealthCheckViewreturning{"status": "ok", "version": "1.0"}. - Add a
POSThandler to the same view that echoes back the JSON body sent by the client. - Restrict the view's renderers to JSON only and verify the browsable API disappears.
Quiz
- (T/F) DRF replaces Django's URL routing system.
- Which method in
APIViewhandles authentication, permissions, and throttling before the handler runs? (initial) - What class-based view do virtually all DRF views ultimately inherit from? (
APIView→ Django'sView) - Name two built-in DRF renderers.
Chapter 2: Serializers
Theory
Serializers are DRF's translation layer between complex Python objects (model instances, querysets) and native Python datatypes that can be rendered into JSON/XML/etc. They also do the reverse: parsing incoming data and validating it before saving. Think of a serializer as Django Forms for APIs — same philosophy (declarative fields, is_valid(), .errors), different destination format.
Two serialization directions:
- Serialization: object → dict → JSON (.data)
- Deserialization: JSON → dict → validated data → object (.validated_data, .save())
Internal Working
- A
Serializeris built from declaredFieldinstances (CharField,IntegerField, etc.), each withto_representation()(object → primitive) andto_internal_value()(primitive → python, with validation). - Calling
serializer.datatriggersto_representation(instance)on the serializer, which loops through fields calling each field'sget_attribute()thento_representation(). - Calling
serializer.is_valid()triggersto_internal_value(data), which loops fields callingrun_validation()(field-levelvalidate_<field>methods) then the serializer's ownvalidate()for cross-field checks. Errors accumulate intoserializer.errorsas a dict. .save()calls eithercreate()orupdate()depending on whether aninstancewas passed to the serializer — these are the methods you override for custom persistence logic.
Serialization: Deserialization:
instance raw data (dict/JSON)
│ │
▼ ▼
to_representation() to_internal_value()
│ │
▼ ▼
OrderedDict (per field) run_validators() + validate()
│ │
▼ ▼
serializer.data serializer.validated_data
│ │
▼ ▼
Renderer → JSON .save() → create()/update()
Diagrams
┌───────────────────────────┐
│ Serializer │
├───────────────────────────┤
│ fields = { │
│ 'name': CharField(), │
│ 'age': IntegerField(), │
│ } │
├───────────────────────────┤
│ to_representation(obj) ───┼──► dict → JSON
│ to_internal_value(data) ───┼──► validated_data
│ validate(attrs) │ (cross-field)
│ create(validated_data) │
│ update(instance, data) │
└───────────────────────────┘
Commands
# Interactively test a serializer in the Django shell
python manage.py shell
>>> from api.serializers import BookSerializer
>>> from api.models import Book
>>> b = Book.objects.first()
>>> BookSerializer(b).data
Code Examples
# api/serializers.py — plain Serializer (manual, full control)
from rest_framework import serializers
class BookSerializer(serializers.Serializer):
id = serializers.IntegerField(read_only=True)
title = serializers.CharField(max_length=200)
price = serializers.DecimalField(max_digits=6, decimal_places=2)
published_date = serializers.DateField()
def validate_price(self, value):
if value <= 0:
raise serializers.ValidationError("Price must be positive.")
return value
def validate(self, attrs):
# cross-field validation
if attrs['published_date'].year < 1450:
raise serializers.ValidationError("Published date looks invalid.")
return attrs
def create(self, validated_data):
from api.models import Book
return Book.objects.create(**validated_data)
def update(self, instance, validated_data):
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
return instance
# Usage in a view
serializer = BookSerializer(data=request.data)
if serializer.is_valid():
book = serializer.save()
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
Real-World Examples
- An e-commerce API serializes a
Productmodel into JSON with computed fields likediscounted_priceusingSerializerMethodField. - A social-media API deserializes an incoming comment payload, validates the comment length, profanity-filters it in
validate_text(), then saves it tied to the authenticated user.
Production Examples
- Netflix-style content APIs use nested/custom serializers to shape a single
Moviemodel into different "views" (list vs. detail) using two separate serializer classes (MovieListSerializer,MovieDetailSerializer) to control payload size for performance. - Payment APIs (e.g., Stripe-like integrations) use serializers with
write_only=Trueon sensitive fields likecard_numberso they're accepted on input but never echoed back in the response.
Common Mistakes
- Forgetting
read_only=Trueon fields likeidorcreated_at, letting clients attempt to set them. - Doing validation logic inside views instead of serializers — breaks reusability and the single-responsibility principle.
- Calling
.databefore.is_valid()on a bound serializer — raisesAssertionError. - Mutating
validated_dataincorrectly (e.g., not popping nested data beforeModel.objects.create(**validated_data)).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
AssertionError: .data was called before .is_valid() |
Accessed .data on a write serializer before validating |
Call is_valid() first |
TypeError: create() got unexpected keyword argument |
Nested/extra fields not popped in create() |
Manually pop nested fields before Model.objects.create() |
| Field always appears empty in output | Field name mismatch with model attribute, or wrong source= |
Use source='model_field_name' |
| Validators not running | Custom validate_<field> typo (must match field name exactly) |
Fix method name |
Security Notes
- Always mark sensitive input-only fields (passwords, tokens)
write_only=True— never let them leak into.data. - Avoid blindly trusting
validated_datafor fields controlling authorization (e.g.,is_admin) — enforce these server-side, never accept them from client-supplied fields directly. - Always validate and sanitize free-text fields to reduce stored-XSS risk when the front-end renders them unescaped.
Interview Questions
- What's the difference between
SerializerandModelSerializer? - Explain the
to_representationvsto_internal_valuelifecycle. - Where would you put cross-field validation logic?
- What does
write_only=Truedo, and when would you use it? - How does
.save()decide between callingcreate()vsupdate()?
Hands-on Lab
Create a Book model and a plain Serializer (not ModelSerializer) with custom create/update/validate methods. Test serialization and deserialization both from the Django shell.
Exercises
- Add a
SerializerMethodFieldcalledis_recentthat returnsTrueifpublished_dateis within the last year. - Add field-level validation ensuring
titlehas no leading/trailing whitespace. - Write a cross-field validator that rejects the object if
priceis 0 andtitlecontains "Premium".
Quiz
- Which serializer method is responsible for cross-field validation? (
validate()) - True/False:
write_onlyfields appear inserializer.data. (False) - What exception type should validation errors raise? (
serializers.ValidationError) - What triggers a call to
to_internal_value()? (is_valid())
Chapter 3: ModelSerializers & Validation
Theory
ModelSerializer auto-generates fields and validators from a Django model, drastically reducing boilerplate. It inspects the model's fields (via introspection of Meta.model._meta) to build serializer fields, generates a default create()/update(), and auto-attaches model-level validators (e.g., unique=True, max_length).
Validation in DRF happens at three levels, always in this order:
1. Field-level (to_internal_value on the field, plus validate_<field_name> on the serializer)
2. Object-level (validate(self, attrs) on the serializer — cross-field)
3. Model-level (only if you explicitly call full_clean(), which DRF does not call automatically)
Internal Working
ModelSerializer.get_fields() calls build_standard_field(), build_relational_field(), or build_nested_field() per model field, mapping Django field types (models.CharField → serializers.CharField, models.ForeignKey → PrimaryKeyRelatedField, etc.) via an internal mapping table (serializer_field_mapping). It also copies over model-field constraints (e.g., max_length, blank, unique) as serializer validators automatically — this is why ModelSerializer "just works" for uniqueness checks without you writing them.
ModelSerializer.Meta.model
│
▼
get_fields() introspects model._meta.fields
│
├── CharField → serializers.CharField(max_length=...)
├── ForeignKey → PrimaryKeyRelatedField(queryset=...)
├── ManyToManyField → PrimaryKeyRelatedField(many=True)
└── unique=True field → UniqueValidator attached automatically
Diagrams
Validation Pipeline (per field, in order):
┌──────────────┐ ┌────────────────┐ ┌──────────────────┐ ┌───────────────┐
│ Field-type │ → │ Field validators│ → │ validate_<field> │ → │ validate(attrs)│
│ coercion │ │ (max_length, │ │ (custom method) │ │ (object-level) │
│ (to_internal_ │ │ UniqueValidator)│ │ │ │ │
│ value) │ │ │ │ │ │ │
└──────────────┘ └────────────────┘ └──────────────────┘ └───────────────┘
│ any failure → collected in serializer.errors, short-circuits .save()
Commands
python manage.py shell
>>> from api.serializers import BookModelSerializer
>>> BookModelSerializer().fields # inspect auto-generated fields
Code Examples
# api/models.py
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=200, unique=True)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
price = models.DecimalField(max_digits=6, decimal_places=2)
published_date = models.DateField()
# api/serializers.py
from rest_framework import serializers
from api.models import Book, Author
class AuthorSerializer(serializers.ModelSerializer):
class Meta:
model = Author
fields = ['id', 'name']
class BookModelSerializer(serializers.ModelSerializer):
author = AuthorSerializer(read_only=True)
author_id = serializers.PrimaryKeyRelatedField(
queryset=Author.objects.all(), source='author', write_only=True
)
class Meta:
model = Book
fields = ['id', 'title', 'price', 'published_date', 'author', 'author_id']
# 'title' unique=True automatically gets a UniqueValidator
def validate_price(self, value):
if value <= 0:
raise serializers.ValidationError("Price must be positive.")
return value
def validate(self, attrs):
if attrs['published_date'].year < 1450:
raise serializers.ValidationError("Invalid published date.")
return attrs
Real-World Examples
- A library-management API uses
ModelSerializerforBookwith a nested read-onlyAuthorand a write-onlyauthor_idfor creation — a very common "read nested, write by ID" pattern. - A job board's
Applicationserializer validates thatresume_file.sizedoesn't exceed 5MB insidevalidate_resume_file().
Production Examples
- Airbnb-style listing APIs use
ModelSerializerwithextra_kwargsto mark fields likehostasread_only, auto-populated fromrequest.userin the view'sperform_create(). - Fintech APIs commonly override
validate()to enforce business rules like "withdrawal amount cannot exceed available balance", keeping the rule at the API boundary in addition to model-level constraints for defense-in-depth.
Common Mistakes
- Assuming
ModelSerializercallsModel.full_clean()— it doesn't; modelclean()methods are silently skipped unless called explicitly. - Exposing all model fields via
fields = '__all__'in production, unintentionally leaking internal fields (e.g.,internal_notes). - Forgetting
source=when a serializer field name differs from the model field, causing silentAttributeErrorswallowed as "field not found." - Using nested writable serializers without overriding
create()/update()— DRF'sModelSerializerdoes NOT support nested writes out of the box.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
AssertionError: The .create() method does not support writable nested fields |
Nested serializer used for write without custom create() |
Override create()/update() manually to handle nested data |
| Unique validation not firing | Field excluded from Meta.fields or read_only=True mistakenly |
Check Meta.fields includes the field, not marked read-only |
ImproperlyConfigured: Field name not valid for model |
Typo in Meta.fields list |
Match exact model field names |
Security Notes
- Never use
fields = '__all__'for user-facing serializers on models containing sensitive columns (password hashes, internal flags) — enumerate fields explicitly. - When exposing
PrimaryKeyRelatedFieldfor foreign keys, scope thequerysetto what the requesting user is authorized to reference (e.g., only their ownAuthorobjects), otherwise users can attach records to unrelated foreign keys (an IDOR-style vulnerability).
Interview Questions
- Why doesn't
ModelSerializercall the model'sfull_clean()? - How would you implement nested writable serializers?
- What's the risk of using
fields = '__all__'? - How does
ModelSerializerdecide which serializer field class to use for a given model field?
Hands-on Lab
Build Author/Book models with a ModelSerializer supporting nested read + write-by-ID for author, then extend it to support fully nested writable creation (create an Author inline while creating a Book) by overriding create().
Exercises
- Add a
UniqueTogetherValidatorensuring(title, author)pairs are unique. - Add
extra_kwargsto makepricewrite-only for a specific "internal" serializer variant. - Implement nested-write support so a POST can create both
AuthorandBookin a single request.
Quiz
- True/False:
ModelSerializerautomatically calls the model'sclean()method. (False) - Which serializer field is auto-generated for a
ForeignKey? (PrimaryKeyRelatedField) - Where do you enforce "field A must be scoped to current user's own related objects"? (Restrict
queryseton thePrimaryKeyRelatedField, or validate invalidate()/view) - What must you override to support nested writable serializers? (
create()andupdate())
Chapter 4: Views — APIView & Generic Views
Theory
DRF offers three escalating levels of view abstraction:
1. Function-based views with @api_view(['GET', 'POST']) — thin wrapper, good for one-off endpoints.
2. APIView (class-based) — full manual control over get/post/put/delete methods.
3. Generic views (ListAPIView, RetrieveAPIView, CreateAPIView, ListCreateAPIView, RetrieveUpdateDestroyAPIView, etc.) — pre-built CRUD via mixins, needing only queryset and serializer_class.
Generic views compose small reusable mixins (ListModelMixin, CreateModelMixin, RetrieveModelMixin, UpdateModelMixin, DestroyModelMixin), each contributing one action (list(), create(), etc.) that generic view classes wire to HTTP methods.
Internal Working
GenericAPIView provides the plumbing every mixin relies on:
- get_queryset() — returns self.queryset (or override for dynamic filtering), applies filter_backends.
- get_object() — fetches a single instance using get_queryset() + lookup_field/lookup_url_kwarg, and calls self.check_object_permissions().
- get_serializer() — instantiates serializer_class with proper context={'request':..., 'view':...}.
A mixin like CreateModelMixin.create() does:
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=201, headers=headers)
perform_create() is the customization hook (e.g., to inject request.user).
Diagrams
GenericAPIView (queryset, serializer_class, get_object, get_queryset)
│
┌───────────────────┼────────────────────┬───────────────────┐
▼ ▼ ▼ ▼
ListModelMixin CreateModelMixin RetrieveModelMixin UpdateModelMixin / DestroyModelMixin
.list() .create() .retrieve() .update() / .destroy()
│ │ │ │
▼ ▼ ▼ ▼
ListAPIView CreateAPIView RetrieveAPIView RetrieveUpdateDestroyAPIView
└──────────────────────┬─────────────────────────────────────┘
▼
ListCreateAPIView, etc. (combine mixins as needed)
Commands
# Test endpoints quickly
curl http://localhost:8000/api/books/
curl -X POST http://localhost:8000/api/books/ -H "Content-Type: application/json" -d '{"title": "Dune", "price": "9.99"}'
curl -X PATCH http://localhost:8000/api/books/1/ -d '{"price": "12.99"}' -H "Content-Type: application/json"
Code Examples
# Function-based view
from rest_framework.decorators import api_view
from rest_framework.response import Response
@api_view(['GET', 'POST'])
def book_list(request):
if request.method == 'GET':
books = Book.objects.all()
serializer = BookModelSerializer(books, many=True)
return Response(serializer.data)
serializer = BookModelSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=201)
# Generic views
from rest_framework import generics, permissions
class BookListCreateView(generics.ListCreateAPIView):
queryset = Book.objects.select_related('author').all()
serializer_class = BookModelSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def perform_create(self, serializer):
serializer.save(created_by=self.request.user)
class BookDetailView(generics.RetrieveUpdateDestroyAPIView):
queryset = Book.objects.all()
serializer_class = BookModelSerializer
lookup_field = 'pk'
# urls.py
from django.urls import path
from api.views import BookListCreateView, BookDetailView
urlpatterns = [
path('books/', BookListCreateView.as_view()),
path('books/<int:pk>/', BookDetailView.as_view()),
]
Real-World Examples
- A blogging platform's
/api/posts/usesListCreateAPIViewfor listing/creating posts, and/api/posts/<slug>/usesRetrieveUpdateDestroyAPIViewwithlookup_field = 'slug'. - An admin-only inventory API restricts
perform_create()to auto-setwarehouse=request.user.warehouse, preventing cross-tenant writes.
Production Examples
- Large APIs (e.g., a logistics tracking platform) use
select_related/prefetch_relatedinsideget_queryset()overrides on generic views to avoid N+1 queries at scale — a critical production optimization. - Companies enforce object-level authorization by overriding
get_object()to callself.check_object_permissions(self.request, obj)combined with customIsOwnerpermission classes (see Chapter 7).
Common Mistakes
- Not overriding
get_queryset()for per-user data scoping — leads to users seeing/editing others' data. - Forgetting
perform_create()/perform_update()hooks and instead trying to overridecreate()entirely, duplicating DRF's boilerplate unnecessarily. - Using
ListCreateAPIViewwhen create should be restricted to admins — permission classes need per-action nuance (solved viaget_permissions()or ViewSets — see Chapter 5).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 404 for existing object | lookup_field/lookup_url_kwarg mismatch with URL conf |
Align lookup_field and the URL <int:pk> name |
| Users see other users' data | get_queryset() not scoped to request.user |
Override get_queryset() to filter by self.request.user |
MethodNotAllowed (405) |
View class doesn't include the mixin for that HTTP verb | Pick the right generic view or add the mixin |
Security Notes
- Always scope
get_queryset()per authenticated user for any endpoint returning private data — this is the #1 real-world DRF vulnerability (broken object-level authorization / IDOR). - Object-level permission checks (
check_object_permissions) only run automatically insideget_object()— if you fetch objects manually, you must call this yourself.
Interview Questions
- What's the difference between a mixin and a generic view in DRF?
- How would you scope a queryset so users only see their own records?
- What hook would you use to auto-assign
request.useron object creation? - Why might you choose
APIViewover a generic view?
Hands-on Lab
Build a full CRUD API for Book using generic views (ListCreateAPIView + RetrieveUpdateDestroyAPIView), then scope get_queryset() so authenticated users only see books they created (add a created_by FK).
Exercises
- Add
perform_update()to auto-update alast_modified_byfield. - Convert the function-based
book_listview into aListCreateAPIView, keeping identical behavior. - Add a custom action for
RetrieveUpdateDestroyAPIViewdenyingDELETEunlessrequest.user.is_staff.
Quiz
- Which mixin provides the
.list()method? (ListModelMixin) - True/False:
get_object()automatically callscheck_object_permissions(). (True) - What generic view combines list + create? (
ListCreateAPIView) - What method do you override to customize object creation logic without rewriting response formatting? (
perform_create)
Chapter 5: ViewSets & Routers
Theory
A ViewSet groups related views (list, create, retrieve, update, destroy) into a single class, mapping actions (list, create, retrieve, update, partial_update, destroy) instead of HTTP-method handlers directly. Paired with a Router, it auto-generates URL patterns — eliminating manual urls.py boilerplate for standard CRUD resources.
ViewSet— likeAPIView, manual action methods.ModelViewSet— combinesGenericAPIView+ all mixins → full CRUD with almost no code.ReadOnlyModelViewSet— onlylist/retrieve.
Internal Working
A Router (e.g., DefaultRouter) inspects a registered viewset and, for each standard action, generates a URL + calls viewset.as_view({'get': 'list', 'post': 'create'}) etc. This dict maps HTTP verbs to viewset action method names — this mapping is the key mechanical difference from APIView, where the HTTP verb name IS the method name.
router.register('books', BookViewSet)
│
▼
Router expands to:
GET/POST /books/ → BookViewSet.as_view({'get':'list','post':'create'})
GET/PUT/PATCH/DELETE /books/{pk}/ → as_view({'get':'retrieve','put':'update','patch':'partial_update','delete':'destroy'})
+ any @action-decorated custom routes
Diagrams
┌───────────────────────────────┐
│ ModelViewSet │
│ (Generic mixins bundled) │
├───────────────────────────────┤
│ list() ← GET /books/ │
│ create() ← POST /books/ │
│ retrieve() ← GET /books/1/ │
│ update() ← PUT /books/1/ │
│ partial_update() ← PATCH │
│ destroy() ← DELETE /books/1/ │
│ @action custom_method() ← any │
│ custom URL │
└───────────────────────────────┘
▲
│ router.register(prefix, viewset)
┌───────────────────────────────┐
│ DefaultRouter / SimpleRouter│
│ auto-generates urlpatterns │
└───────────────────────────────┘
Commands
curl http://localhost:8000/api/books/
curl http://localhost:8000/api/books/1/
curl -X POST http://localhost:8000/api/books/1/mark_sold/ # custom @action route
Code Examples
# api/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.select_related('author').all()
serializer_class = BookModelSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly]
def get_queryset(self):
qs = super().get_queryset()
if self.request.query_params.get('cheap'):
qs = qs.filter(price__lt=10)
return qs
@action(detail=True, methods=['post'])
def mark_sold(self, request, pk=None):
book = self.get_object()
book.sold = True
book.save(update_fields=['sold'])
return Response({'status': 'sold'})
@action(detail=False, methods=['get'])
def bestsellers(self, request):
qs = self.get_queryset().order_by('-sales_count')[:10]
serializer = self.get_serializer(qs, many=True)
return Response(serializer.data)
# config/urls.py
from rest_framework.routers import DefaultRouter
from api.views import BookViewSet
router = DefaultRouter()
router.register(r'books', BookViewSet, basename='book')
urlpatterns = [
path('api/', include(router.urls)),
]
Real-World Examples
- An HR system's
EmployeeViewSetexposes standard CRUD plus a custom@action(detail=True) def promote(self, request, pk=None)endpoint at/employees/{id}/promote/. - A ride-sharing backend's
TripViewSethas a list-level@action(detail=False) def active(self, request)returning only in-progress trips.
Production Examples
- Large REST APIs (e.g., internal microservices at fintech/e-commerce companies) standardize on
ModelViewSet+DefaultRouterbecause it keepsurls.pytiny and auto-generates a consistent API surface (list/detail endpoints, OPTIONS metadata) across dozens of resources — critical for maintainability at scale. - Teams often build a custom base
viewsets.ModelViewSetsubclass (e.g.,AuditedModelViewSet) that all resource viewsets inherit from, centralizing audit-logging (perform_create/perform_update/perform_destroyoverrides) across the entire API.
Common Mistakes
- Forgetting
basenamewhenquerysetis dynamically generated (no static.querysetattribute) — router can't infer it and raises anAssertionError. - Overusing
@actionfor logic that should really be a separate resource/viewset (e.g., cramming unrelated business logic into one bloated viewset). - Not distinguishing
detail=Truevsdetail=Falsecorrectly, producing incorrect URLs (/books/bestsellers/vs/books/{pk}/bestsellers/).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
AssertionError: basename not set |
Dynamic/no queryset attribute on viewset |
Pass basename= explicitly in router.register() |
Custom @action route 404s |
URL not regenerated / router not reloaded, or wrong methods=[] |
Restart dev server; verify HTTP method in the action decorator |
| Duplicate/overlapping URL patterns | Two routers registered at the same prefix | Use unique prefixes/basenames |
Security Notes
- Custom
@actionmethods bypass none of DRF's authentication/permission pipeline — but if you add per-action permission logic, rememberpermission_classeson the viewset level applies to all actions unless overridden viaget_permissions(). - Be deliberate with
detail=Falseactions that expose aggregate/sensitive data (e.g.,bestsellersmight leak business metrics) — scope permissions per action.
Interview Questions
- What's the difference between
ViewSetandModelViewSet? - How does a
Routermap HTTP verbs to viewset actions internally? - When would you use
@action(detail=True)vsdetail=False? - Why might a router need an explicit
basename?
Hands-on Lab
Convert the Chapter 4 BookListCreateView/BookDetailView generic views into a single BookViewSet registered with DefaultRouter. Add a custom @action for toggling a featured boolean field.
Exercises
- Add
get_permissions()toBookViewSetso only staff candestroy(), but anyone canlist()/retrieve(). - Add a
@action(detail=False)returning books grouped by author. - Register a second
ReadOnlyModelViewSetforAuthorin the same router.
Quiz
- True/False:
ViewSetclasses map HTTP verbs directly to method names likeAPIView. (False — they map to actions likelist/create) - What decorator adds a custom endpoint to a ViewSet? (
@action) - What router class produces both the resource routes and a browsable API root view? (
DefaultRouter) - What's needed if your viewset has no static
querysetattribute? (basenamepassed torouter.register)
Chapter 6: Authentication
Theory
Authentication identifies who is making the request; it does not decide what they can do (that's Permissions, Chapter 7). DRF supports pluggable authentication classes checked in order until one succeeds or all fail (falls back to AnonymousUser). Built-in schemes:
- SessionAuthentication — Django's cookie-based session, needs CSRF for unsafe methods.
- BasicAuthentication — HTTP Basic auth (username:password base64), only over HTTPS.
- TokenAuthentication — simple static token in Authorization: Token <key> header.
- Third-party: JWT (djangorestframework-simplejwt), OAuth2 (django-oauth-toolkit).
Internal Working
APIView.perform_authentication() calls request.user, a lazy property that iterates self.authentication_classes, calling each authenticate(request) until one returns a (user, auth) tuple or raises AuthenticationFailed. If none succeed and none raise, request.user becomes AnonymousUser.
request.user (lazy)
│
▼
for auth_class in authentication_classes:
result = auth_class().authenticate(request)
if result is not None:
user, auth = result
break
# if AuthenticationFailed raised → 401 immediately, stop
else:
user = AnonymousUser()
Token flow (TokenAuthentication):
Client: Authorization: Token abc123
│
▼
TokenAuthentication.authenticate()
│
▼
Token.objects.get(key='abc123') → user attached to token
│
▼
request.user = token.user
Diagrams
┌─────────────┐ Authorization Header ┌───────────────────────┐
│ Client │ ───────────────────────►│ Authentication Classes │
└─────────────┘ │ (tried in order) │
├────────────────────────┤
│ 1. SessionAuthentication│
│ 2. TokenAuthentication │
│ 3. JWTAuthentication │
└───────────┬────────────┘
▼
request.user / request.auth
│
▼
Permission checks
Commands
pip install djangorestframework-simplejwt
# Obtain token
curl -X POST http://localhost:8000/api/token/ -d "username=amit&password=secret"
# Use token
curl http://localhost:8000/api/books/ -H "Authorization: Bearer <access_token>"
# Generate a legacy DRF auth token for a user (built-in TokenAuthentication)
python manage.py drf_create_token <username>
Code Examples
# settings.py — JWT setup
INSTALLED_APPS += ['rest_framework_simplejwt']
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
}
# urls.py
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
urlpatterns += [
path('api/token/', TokenObtainPairView.as_view(), name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'),
]
# Custom authentication class example: API-key based
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
class APIKeyAuthentication(BaseAuthentication):
def authenticate(self, request):
key = request.headers.get('X-API-Key')
if not key:
return None # let other auth classes try, or fall through to anonymous
try:
client = APIClient.objects.get(key=key, active=True)
except APIClient.DoesNotExist:
raise AuthenticationFailed('Invalid API key.')
return (client.user, None)
Real-World Examples
- A mobile app authenticates users with JWT: login returns
access/refreshtokens; the app stores them securely and sendsAuthorization: Bearer <access>on every request, refreshing when expired. - A partner-integration API issues long-lived API keys (custom
APIKeyAuthentication) to B2B clients instead of user-based JWTs.
Production Examples
- SaaS platforms typically combine
JWTAuthenticationfor the API with short-lived access tokens (5–15 min) and rotating refresh tokens, with refresh-token blacklisting on logout (simplejwt'stoken_blacklistapp). - Enterprise APIs behind an API gateway sometimes delegate authentication entirely to the gateway (mTLS/OAuth2 introspection) and use a custom DRF authentication class that trusts a signed header injected by the gateway.
Common Mistakes
- Using
BasicAuthenticationover plain HTTP (not HTTPS) — credentials sent effectively in plaintext (base64 is not encryption). - Storing JWTs in
localStorageon web frontends (XSS-exploitable) instead ofhttpOnlysecure cookies. - Not setting token expiry, or using
TokenAuthentication's single non-expiring token in a long-lived production system. - Forgetting CSRF protection when combining
SessionAuthenticationwith browser-based clients performing unsafe methods.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 401 despite correct credentials | Wrong header format, e.g., missing Bearer/Token prefix |
Match the exact scheme prefix expected by the class |
| CSRF 403 on POST with session auth | Missing X-CSRFToken header on unsafe methods |
Include CSRF token from cookie in request headers |
| JWT "token is invalid or expired" | Clock skew or expired access token | Implement refresh flow; sync server clocks |
request.user is AnonymousUser unexpectedly |
Auth class returned None silently (e.g., header missing) |
Check header name/casing, verify auth class order |
Security Notes
- Always require HTTPS in production; never allow Basic/Token/JWT auth over plain HTTP.
- Rotate and expire tokens; use refresh-token blacklisting on logout to prevent reuse of stolen refresh tokens.
- Rate-limit authentication endpoints (login/token) separately, since they're prime brute-force targets (pair with Chapter 8 throttling).
- Never log raw tokens/credentials in application logs.
Interview Questions
- What's the difference between authentication and permissions in DRF?
- Walk through the JWT authentication flow end-to-end.
- Why is
BasicAuthenticationrisky without HTTPS? - How would you implement a custom API-key authentication scheme?
- What happens if multiple authentication classes are configured and the first one fails?
Hands-on Lab
Add djangorestframework-simplejwt to the Book API project. Protect BookViewSet's write actions behind JWT auth, obtain a token via /api/token/, and confirm unauthenticated requests get 401 on POST but 200 on GET (read-only).
Exercises
- Implement a custom
APIKeyAuthenticationclass and apply it only to a specific viewset. - Configure
simplejwtaccess token lifetime to 5 minutes and refresh token lifetime to 1 day. - Add token blacklisting on logout using
rest_framework_simplejwt.token_blacklist.
Quiz
- True/False: Authentication decides what an authenticated user is allowed to do. (False — that's permissions)
- What method must a custom authentication class implement? (
authenticate(self, request)) - What does returning
Nonefromauthenticate()signal vs raisingAuthenticationFailed? (None= try next class/anonymous; raise = immediate 401) - Name one risk of storing JWTs in browser
localStorage. (XSS token theft)
Chapter 7: Permissions
Theory
Permissions decide what an authenticated (or anonymous) user is allowed to do, evaluated after authentication, before the view handler runs (and again for object-level checks in get_object()). DRF ships:
- AllowAny, IsAuthenticated, IsAdminUser, IsAuthenticatedOrReadOnly, DjangoModelPermissions.
Custom permissions subclass BasePermission and implement has_permission() (view-level) and/or has_object_permission() (object-level).
Internal Working
APIView.check_permissions(request) loops self.get_permissions() (instantiated from permission_classes), calling .has_permission(request, self) on each — all must return True, else PermissionDenied/NotAuthenticated is raised (403/401).
For object-level checks, get_object() calls self.check_object_permissions(request, obj), looping permissions' .has_object_permission(request, self, obj) — again ALL must pass. Note: object-level permissions are not automatically checked for list() (no single object) — you must filter in get_queryset() instead.
check_permissions(request) check_object_permissions(request, obj)
│ │
▼ ▼
for perm in permission_classes: for perm in permission_classes:
if not perm.has_permission(...): if not perm.has_object_permission(...):
raise PermissionDenied raise PermissionDenied
Diagrams
View-level Object-level
┌─────────────────┐ ┌──────────────────────┐
│ has_permission() │ │ has_object_permission()│
│ runs BEFORE │ │ runs INSIDE get_object()│
│ handler executes │ │ for retrieve/update/ │
│ (all requests) │ │ destroy (needs an obj) │
└─────────────────┘ └──────────────────────┘
│ │
▼ ▼
403 Forbidden / 401 403 Forbidden
Commands
curl http://localhost:8000/api/books/1/ -H "Authorization: Bearer <token>"
# Test with a different user's token to confirm object-level denial
Code Examples
# api/permissions.py
from rest_framework import permissions
class IsOwnerOrReadOnly(permissions.BasePermission):
"""Object-level: only the owner can edit/delete; anyone can read."""
def has_object_permission(self, request, view, obj):
if request.method in permissions.SAFE_METHODS:
return True
return obj.owner_id == request.user.id
class IsStaffOrReadOnly(permissions.BasePermission):
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return bool(request.user and request.user.is_staff)
# views.py
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookModelSerializer
permission_classes = [permissions.IsAuthenticatedOrReadOnly, IsOwnerOrReadOnly]
def get_permissions(self):
# Per-action override: only staff may destroy
if self.action == 'destroy':
return [permissions.IsAdminUser()]
return super().get_permissions()
Real-World Examples
- A blogging platform lets any authenticated user create posts but only the post's author (or staff) can edit/delete it — implemented via
IsOwnerOrReadOnly. - A multi-tenant SaaS restricts
has_permission()to checkrequest.user.tenant_id == view.kwargs['tenant_id']before allowing access to tenant-scoped resources.
Production Examples
- E-commerce platforms implement fine-grained permission classes per action (
get_permissions()override) — e.g.,list/retrieveopen to all,createrequires login,destroyrequires admin — extremely common pattern in production ModelViewSets. - Enterprise systems integrate
DjangoModelPermissionscombined with Django's built-inauthapp group/permission model to map REST actions to Django'sadd_*/change_*/delete_*permissions, integrating with existing admin-managed RBAC.
Common Mistakes
- Assuming object-level permissions apply automatically to
list()— they don't; unscopedget_queryset()combined with only object-level checks leaks other users' data in list views. - Forgetting
permissions.SAFE_METHODShandling, accidentally blocking GET/HEAD/OPTIONS too. - Returning
Falsefromhas_permission()without proper HTTP status differentiation — DRF returns 403 if user is authenticated, 401 if not, automatically, but only ifIsAuthenticated/similar denies it; custom permissions must be careful not to swallow this distinction.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Users can view others' private objects in list endpoint | Only object-level perms configured, get_queryset() unscoped |
Filter get_queryset() by request.user too |
| 403 even for the resource owner | has_object_permission comparing wrong field (e.g., obj.owner vs obj.owner_id) |
Fix comparison logic/types |
AttributeError: 'AnonymousUser' has no attribute... |
Custom permission assumes authenticated user without checking | Guard with request.user and request.user.is_authenticated |
Security Notes
- Broken Object-Level Authorization (IDOR) is one of the most common real-world API vulnerabilities — always pair permission classes with properly scoped querysets.
- Never rely solely on hiding IDs (security through obscurity) — enforce permissions explicitly.
- Combine multiple permission classes deliberately; remember ALL must pass (AND logic) — use custom composite classes if you need OR logic.
Interview Questions
- Difference between
has_permission()andhas_object_permission()? - Why doesn't object-level permission checking apply to
list()views? - How would you implement "only the owner or an admin can edit" logic?
- What's the security risk of scoping only permissions but not querysets?
Hands-on Lab
Add owner FK to the Book model. Implement IsOwnerOrReadOnly and scope get_queryset() in BookViewSet so list() only returns the requesting user's books (or all books if staff).
Exercises
- Implement a composite permission class allowing access if EITHER the user is the owner OR staff (OR logic).
- Add per-action permissions:
list/retrieveopen to anyone,createrequires authentication,update/destroyrequires ownership. - Write a permission class enforcing a maximum of 5 books per non-staff user (checked in
has_permissionusingrequest.user.book_set.count()).
Quiz
- True/False: Object-level permissions are checked automatically during
list(). (False) - Which built-in constant lists the "read-only" HTTP methods? (
permissions.SAFE_METHODS) - What method would you override on a ViewSet to vary permissions per action? (
get_permissions()) - Is permission-class evaluation AND or OR by default across a list of classes? (AND — all must pass)
Chapter 8: Throttling & Rate Limiting
Theory
Throttling limits how often a client can call the API, protecting against abuse, brute-force attacks, and runaway costs. Distinct from permissions (which are binary allow/deny) — throttles are time-windowed rate limits. Built-in classes: AnonRateThrottle, UserRateThrottle, ScopedRateThrottle.
Internal Working
APIView.check_throttles() loops self.get_throttles(), calling throttle.allow_request(request, self). Each throttle uses Django's cache framework to store a timestamped history of requests keyed by self.get_cache_key() (typically IP for anonymous, user ID for authenticated). If the number of requests within the configured rate (e.g., '100/day') window is exceeded, allow_request() returns False → DRF raises Throttled (429) with a Retry-After header computed by wait().
check_throttles(request)
│
▼
for throttle in throttle_classes:
if not throttle.allow_request(request, view):
raise Throttled(wait=throttle.wait()) → HTTP 429 + Retry-After header
Cache-backed sliding window:
cache_key = "throttle_user_42"
history = cache.get(cache_key, []) # list of past request timestamps
history = [t for t in history if t > now - duration] # drop expired
if len(history) >= num_requests:
return False # throttled
history.insert(0, now)
cache.set(cache_key, history, duration)
return True
Diagrams
Request ──► check_permissions() ──► check_throttles() ──► handler
│
┌────────┴─────────┐
│ Cache (Redis/ │
│ Memcached/local) │
│ stores timestamps │
│ per user/IP/scope │
└────────────────────┘
Commands
# Hammer an endpoint to trigger throttling (dev testing)
for i in {1..20}; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8000/api/books/; done
# Check response headers for rate-limit info
curl -i http://localhost:8000/api/books/
Code Examples
# settings.py
REST_FRAMEWORK = {
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '20/min',
'user': '100/min',
'login': '5/min', # custom scope
},
'CACHES': {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
}
},
}
# Custom scoped throttle for a sensitive endpoint (e.g., login)
from rest_framework.throttling import ScopedRateThrottle
class LoginView(APIView):
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'login'
def post(self, request):
...
# Custom throttle class example
from rest_framework.throttling import SimpleRateThrottle
class BurstRateThrottle(SimpleRateThrottle):
scope = 'burst'
rate = '10/min'
def get_cache_key(self, request, view):
ident = request.user.pk if request.user.is_authenticated else self.get_ident(request)
return self.cache_format % {'scope': self.scope, 'ident': ident}
Real-World Examples
- A public weather API allows anonymous users 20 requests/min, but authenticated (paid) users get 1000/min — implemented with
AnonRateThrottle+UserRateThrottletiers. - A login endpoint uses
ScopedRateThrottlelimited to 5 attempts/min per IP to slow down credential-stuffing attacks.
Production Examples
- API platforms like Stripe/Twilio-style services expose rate-limit info via response headers (
X-RateLimit-Remaining,Retry-After) — DRF'sThrottledexception naturally setsRetry-After; teams often add custom middleware to addX-RateLimit-*headers for client-friendliness. - High-traffic production systems back throttle state with Redis (not Django's local-memory cache) since local-memory caching doesn't work correctly across multiple app server processes/pods.
Common Mistakes
- Using Django's default
LocMemCachefor throttling in a multi-process/multi-server deployment — each process has its own cache, so limits aren't actually enforced globally. - Setting throttle rates too aggressively for legitimate bursty traffic (e.g., a dashboard that fires 10 parallel requests on page load).
- Forgetting that throttle counters persist across deploys if using persistent cache (Redis) — can cause confusing "still throttled" behavior right after a fix.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Throttling doesn't work at all in production (but works locally) | Using LocMemCache across multiple worker processes |
Switch to Redis/Memcached shared cache backend |
| Legitimate users hitting 429 too often | Rate too low, or IP-based throttling behind a shared NAT/proxy | Tune rate, use X-Forwarded-For correctly for get_ident() |
Retry-After header missing |
Client library not reading Throttled.wait |
Ensure exception handler doesn't override/strip the header |
Security Notes
- Rate-limit authentication-sensitive endpoints (login, password reset, OTP verification) more aggressively than regular read endpoints — a top defense against brute-force and enumeration attacks.
- Combine throttling with proper logging/alerting on repeated 429s from the same IP/user — could indicate an attack in progress.
Interview Questions
- What's the difference between permissions and throttling?
- Why is Redis preferred over local memory cache for throttling in production?
- How would you implement stricter throttling on a login endpoint specifically?
- What HTTP status code and header does DRF return when a request is throttled?
Hands-on Lab
Configure AnonRateThrottle and UserRateThrottle on the Book API, set a low rate (3/min) for testing, and confirm you receive 429 Too Many Requests with a Retry-After header after exceeding it.
Exercises
- Add a
ScopedRateThrottlefor a login endpoint limited to 5 requests/min. - Implement a custom throttle that gives staff users unlimited requests (bypass throttling for
is_staff). - Switch the cache backend to Redis and verify throttling persists correctly across two separate
manage.py runserverprocesses on different ports.
Quiz
- True/False: Throttling is a binary allow/deny check like permissions. (False — it's rate/time-windowed)
- What HTTP status code indicates throttling? (429)
- Why does
LocMemCachefail for throttling in multi-process deployments? (Cache is process-local, not shared) - Name the built-in throttle class useful for a specific single endpoint like "login." (
ScopedRateThrottle)
Chapter 9: Pagination
Theory
Pagination splits large result sets into manageable "pages," essential for both performance (avoiding huge payloads/DB scans) and usability. DRF ships three main styles:
- PageNumberPagination — ?page=2
- LimitOffsetPagination — ?limit=10&offset=20
- CursorPagination — opaque cursor tokens, stable under concurrent inserts/deletes (best for infinite-scroll feeds).
Internal Working
GenericAPIView.paginate_queryset(queryset) invokes self.paginator.paginate_queryset(queryset, self.request, view=self), which slices the queryset (typically via SQL LIMIT/OFFSET for the first two styles) and stores pagination metadata. list() mixin then calls self.get_paginated_response(serializer.data) to wrap results in {"count":, "next":, "previous":, "results": [...]}.
CursorPagination instead encodes a base64 cursor pointing to an ordering position (e.g., last seen id/created_at), avoiding the "page drift" problem of offset pagination when rows are inserted/deleted between requests.
list() mixin
│
▼
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset) # slices via paginator
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
Diagrams
PageNumberPagination: LimitOffsetPagination: CursorPagination:
?page=2&page_size=10 ?limit=10&offset=20 ?cursor=cD0yMDI1LTA3...
│ │ │
▼ ▼ ▼
SQL: LIMIT 10 OFFSET 10 SQL: LIMIT 10 OFFSET 20 SQL: WHERE id < last_id
ORDER BY id DESC LIMIT 10
Simple, but drifts if rows Flexible client control, Stable under concurrent
inserted/deleted mid-browse same drift risk writes; opaque, non-jumpable
Commands
curl "http://localhost:8000/api/books/?page=2"
curl "http://localhost:8000/api/books/?limit=5&offset=10"
curl "http://localhost:8000/api/books/?cursor=cD0yMDI1LTA3LTA0"
Code Examples
# settings.py — global default
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10,
}
# Custom pagination class with configurable page size
from rest_framework.pagination import PageNumberPagination
class StandardResultsPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
# Per-view override
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.all()
serializer_class = BookModelSerializer
pagination_class = StandardResultsPagination
# CursorPagination example, useful for feeds
from rest_framework.pagination import CursorPagination
class BookCursorPagination(CursorPagination):
page_size = 20
ordering = '-created_at' # must match an indexed, unique-ish, sortable field
Real-World Examples
- A product catalog API uses
PageNumberPaginationfor simple "Page 1, 2, 3..." navigation in an admin dashboard. - A social-media feed uses
CursorPaginationordered by-created_atso infinite scroll doesn't show duplicate/skipped posts even as new posts are inserted.
Production Examples
- High-traffic feed systems (Twitter/Instagram-style) exclusively use cursor-based pagination in production because offset pagination becomes both slow (large
OFFSETscans) and inconsistent at scale. - E-commerce search-result APIs typically use
LimitOffsetPaginationcombined with search-engine-side pagination (Elasticsearch) rather than raw DB offset pagination for performance at high offsets.
Common Mistakes
- Using
PageNumberPagination/LimitOffsetPaginationfor very large or frequently-changing datasets, leading to "duplicate/missing rows" as offsets drift. - Not setting
max_page_size, allowing clients to request?page_size=100000and overload the server. - Forgetting
orderingmust be a stable, indexed field forCursorPagination— non-unique or unindexed ordering fields cause poor performance and unstable cursors.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Duplicate/missing items across pages | Data mutated between page requests (offset pagination) | Switch to CursorPagination for volatile datasets |
?page_size= has no effect |
page_size_query_param not set on the pagination class |
Set page_size_query_param = 'page_size' |
| Slow queries at high page numbers | Large OFFSET requiring the DB to scan+discard rows |
Use cursor pagination or keyset pagination pattern |
next/previous links show wrong scheme (http vs https) |
Missing/incorrect proxy headers | Configure USE_X_FORWARDED_HOST/SECURE_PROXY_SSL_HEADER |
Security Notes
- Cap
max_page_sizeto prevent clients from requesting excessively large pages (a low-effort DoS vector). - Don't expose internal DB row counts/IDs unnecessarily in pagination metadata if that leaks business-sensitive volume information (e.g., total user count) — consider omitting
countfor very large or sensitive tables (custom pagination class without a total count query, which also avoids a slowCOUNT(*)).
Interview Questions
- Compare
PageNumberPagination,LimitOffsetPagination, andCursorPagination— when would you choose each? - Why does cursor pagination handle concurrent writes better than offset pagination?
- What's the performance downside of large
OFFSETvalues in SQL? - How would you cap the maximum page size a client can request?
Hands-on Lab
Add PageNumberPagination (page_size=5) to the Book API, browse a few pages, then insert a new book mid-browsing and observe how offset-based pagination can shift/duplicate results. Then switch to CursorPagination and repeat the experiment.
Exercises
- Implement a custom pagination class capping
page_sizeat 50 with a default of 10. - Add
CursorPaginationordered by-published_dateto a "recent books" endpoint. - Modify pagination output to include a custom field,
total_pages, inget_paginated_response().
Quiz
- Which pagination style is most resistant to "drift" caused by concurrent inserts/deletes? (
CursorPagination) - True/False:
CursorPaginationallows jumping directly to page 5. (False — cursors are sequential, not random-access) - What setting limits the maximum page size a client can request? (
max_page_size) - What SQL clause commonly implements
LimitOffsetPagination? (LIMIT ... OFFSET ...)
Chapter 10: Filtering, Searching & Ordering
Theory
Filter backends let clients narrow/sort querysets via query params without writing custom view logic per filter. DRF's filter_backends setting applies a chain of backend classes to get_queryset() results before pagination. Common backends:
- django_filter.rest_framework.DjangoFilterBackend — exact-match/range filters on model fields via filterset_fields or a FilterSet class.
- rest_framework.filters.SearchFilter — ?search=term full-text-ish search across search_fields.
- rest_framework.filters.OrderingFilter — ?ordering=-price sorting.
Internal Working
GenericAPIView.filter_queryset(queryset) loops self.filter_backends, calling backend().filter_queryset(request, queryset, view) on each — each backend returns a progressively narrowed/reordered queryset. Since Django querysets are lazy, backends compose additional .filter()/.order_by() calls without hitting the DB until evaluated (e.g., during pagination slicing or serialization).
filter_queryset(queryset)
│
▼
for backend in filter_backends:
queryset = backend().filter_queryset(request, queryset, view)
# e.g. DjangoFilterBackend adds .filter(author=5)
# SearchFilter adds .filter(Q(title__icontains='dune'))
# OrderingFilter adds .order_by('-price')
return queryset
Diagrams
Raw queryset
│
▼
┌─────────────────────┐
│ DjangoFilterBackend │ ?author=3&price__lt=20
└──────────┬───────────┘
▼
┌─────────────────────┐
│ SearchFilter │ ?search=dune
└──────────┬───────────┘
▼
┌─────────────────────┐
│ OrderingFilter │ ?ordering=-published_date
└──────────┬───────────┘
▼
Paginated + serialized response
Commands
pip install django-filter
curl "http://localhost:8000/api/books/?author=3"
curl "http://localhost:8000/api/books/?search=dune"
curl "http://localhost:8000/api/books/?ordering=-price"
curl "http://localhost:8000/api/books/?price__gte=10&price__lte=50&ordering=price"
Code Examples
# settings.py
INSTALLED_APPS += ['django_filters']
REST_FRAMEWORK = {
'DEFAULT_FILTER_BACKENDS': [
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'rest_framework.filters.OrderingFilter',
],
}
# api/filters.py — custom FilterSet for range filters
import django_filters
from api.models import Book
class BookFilter(django_filters.FilterSet):
min_price = django_filters.NumberFilter(field_name='price', lookup_expr='gte')
max_price = django_filters.NumberFilter(field_name='price', lookup_expr='lte')
class Meta:
model = Book
fields = ['author', 'min_price', 'max_price']
# views.py
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.select_related('author').all()
serializer_class = BookModelSerializer
filterset_class = BookFilter
search_fields = ['title', 'author__name']
ordering_fields = ['price', 'published_date']
ordering = ['-published_date'] # default ordering
Real-World Examples
- A job-listing site filters
/api/jobs/?location=Remote&min_salary=60000&search=python&ordering=-posted_date. - A hotel-booking API restricts
ordering_fieldsto a whitelist (price,rating) to prevent clients from sorting on arbitrary (potentially sensitive or unindexed) fields.
Production Examples
- E-commerce search/filter panels (facet filters: brand, price range, rating) map directly onto
django-filterFilterSetclasses, often combined with Elasticsearch/OpenSearch for full-textsearch_fieldsat scale instead of DBicontains(which doesn't scale well beyond moderate data sizes). - Multi-tenant SaaS APIs always AND an implicit tenant filter into
get_queryset()before applying user-supplied filters, ensuring filters can never be used to escape tenant isolation.
Common Mistakes
- Leaving
ordering_fields = '__all__', letting clients sort by any field including ones without DB indexes — potential performance/DoS risk on large tables. - Using
SearchFilter's defaulticontainssearch on unindexed large text columns in production — slow full table scans; considertrigram/full-text search indexes (Postgres) instead. - Allowing filters on fields that leak information across tenants/other users without checking that filtering happens on an already-scoped queryset.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Filter query param ignored | Field not listed in filterset_fields/FilterSet.Meta.fields |
Add the field explicitly |
search param does nothing |
search_fields not set on the view |
Add search_fields = [...] |
FieldError on ordering |
Field not in ordering_fields whitelist, or invalid field name |
Fix the field name / whitelist |
| Filters slow on large tables | Missing DB index on filtered/ordered column | Add db_index=True or a composite index via migration |
Security Notes
- Never expose
ordering_fields = '__all__'orfilterset_fields = '__all__'blindly on models with sensitive/unindexed columns — always whitelist explicitly. - Always apply tenant/ownership scoping in
get_queryset()before filter backends run, since filter backends only narrow further — they can't be used to bypass an already-scoped base queryset if done correctly.
Interview Questions
- How does
filter_queryset()chain multiple filter backends together? - What's the difference between
SearchFilterand aDjangoFilterBackendexact-match filter? - Why is
ordering_fields = '__all__'a bad practice in production? - How would you implement a price-range filter (
min_price/max_price)?
Hands-on Lab
Add django-filter, SearchFilter, and OrderingFilter to BookViewSet. Build a custom BookFilter supporting author, min_price, max_price. Test combined query params like ?search=dune&min_price=5&ordering=-price.
Exercises
- Add a
SearchFiltersearching acrosstitleandauthor__name. - Whitelist
ordering_fieldsto onlypriceandpublished_date. - Add a custom
FilterSetmethod filter forpublished_afterusing a customfilter_method.
Quiz
- What backend class enables
?search=termbehavior? (SearchFilter) - True/False: Filter backends run before pagination. (True)
- What's the risk of allowing
ordering_fields = '__all__'? (Sorting on unindexed/sensitive fields, perf/security risk) - What third-party package provides
FilterSetclasses for exact/range filtering? (django-filter)
Chapter 11: Testing DRF APIs
Theory
DRF provides APIClient (extends Django's Client) and APITestCase/APISimpleTestCase for writing HTTP-level tests against your API without running a real server. Tests should cover: status codes, response payload shape, permission enforcement, validation errors, and edge cases (pagination boundaries, filters, throttling).
Internal Working
APIClient wraps requests through the same WSGI-like dispatch DRF uses internally — client.get()/post()/put()/patch()/delete() builds a Django test request, routes it through URL resolution into your view, and returns a Response object with .status_code, .data (parsed, not raw bytes — a DRF-specific convenience over Django's .content).
APITestCase wraps each test in a DB transaction that's rolled back afterward (via Django's TransactionTestCase/TestCase machinery), so tests don't pollute each other's data.
self.client = APIClient()
self.client.force_authenticate(user=some_user) # bypasses real auth for speed
response = self.client.post('/api/books/', data={...}, format='json')
assert response.status_code == 201
assert response.data['title'] == 'Dune'
Diagrams
Test Method
│
▼
APIClient.post(url, data, format='json')
│
▼
Django test request built → routed via urls.py → View.dispatch()
│
▼
Response captured (status_code, .data already parsed)
│
▼
assertEqual / assertContains / custom assertions
Commands
python manage.py test
python manage.py test api.tests.BookAPITests
python manage.py test --parallel
coverage run manage.py test && coverage report
pytest # if using pytest-django
pytest -k "test_create_book"
Code Examples
# api/tests.py
from django.contrib.auth.models import User
from rest_framework.test import APITestCase
from rest_framework import status
from api.models import Book, Author
class BookAPITests(APITestCase):
def setUp(self):
self.user = User.objects.create_user(username='amit', password='pass1234')
self.author = Author.objects.create(name='Frank Herbert')
self.book = Book.objects.create(
title='Dune', author=self.author, price='9.99', published_date='1965-08-01'
)
def test_list_books_unauthenticated(self):
response = self.client.get('/api/books/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data['results']), 1)
def test_create_book_requires_authentication(self):
payload = {'title': 'New Book', 'author_id': self.author.id, 'price': '5.00',
'published_date': '2024-01-01'}
response = self.client.post('/api/books/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_book_authenticated(self):
self.client.force_authenticate(user=self.user)
payload = {'title': 'New Book', 'author_id': self.author.id, 'price': '5.00',
'published_date': '2024-01-01'}
response = self.client.post('/api/books/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Book.objects.count(), 2)
def test_negative_price_rejected(self):
self.client.force_authenticate(user=self.user)
payload = {'title': 'Bad Book', 'author_id': self.author.id, 'price': '-5.00',
'published_date': '2024-01-01'}
response = self.client.post('/api/books/', payload, format='json')
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn('price', response.data)
Real-World Examples
- A CI pipeline runs
python manage.py test(orpytest) on every pull request, blocking merge if any API contract test fails. - QA teams write "contract tests" verifying response shape (
assertEqual(set(response.data.keys()), {'id','title','price'})) to catch accidental breaking API changes.
Production Examples
- Enterprise Django/DRF projects enforce a minimum test-coverage threshold (e.g., 80%) via
coverage.pyin CI, specifically requiring coverage onviews.py/serializers.py/permissions.py. - Teams use
factory_boyfor generating realistic test fixtures instead of manually constructing model instances, keeping tests maintainable as models grow.
Common Mistakes
- Testing only "happy path" (200/201) and skipping permission-denial, validation-error, and edge-case tests.
- Using real
authenticate()flows (real login requests) in every test instead offorce_authenticate(), drastically slowing the test suite. - Not isolating tests — relying on ordering or shared mutable state between test methods.
- Forgetting
format='json'onclient.post(), which defaults to multipart form data and can behave differently than your real JSON API clients.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Tests pass individually but fail together | Shared state / non-isolated DB fixtures | Ensure setUp() recreates all needed objects; check no class-level mutable state |
response.data is None |
Wrong Content-Type/format in request, or renderer mismatch |
Pass format='json' explicitly |
| Slow test suite | Real auth handshakes, large DB fixtures per test | Use force_authenticate(), setUpTestData() for shared read-only fixtures |
| Flaky tests around dates/times | Hardcoded "now" comparisons | Use freezegun or inject a clock/mock |
Security Notes
- Explicitly write tests asserting that unauthorized/unauthenticated users get 401/403 and cannot read/write other users' data (IDOR regression tests) — these are the tests most often skipped, and the vulnerabilities most often shipped.
- Include tests for rate-limiting/throttling behavior on sensitive endpoints so throttle misconfigurations are caught before production.
Interview Questions
- What's the difference between
force_authenticate()and a real login request in tests? - Why does
APITestCasewrap tests in DB transactions? - What kinds of tests are most commonly missing in real-world API test suites?
- How would you test that a serializer correctly rejects invalid input?
Hands-on Lab
Write a full APITestCase suite for BookViewSet covering: unauthenticated list (200), unauthenticated create (401), authenticated create (201), invalid data (400), and object-level permission denial (403) for editing another user's book.
Exercises
- Add a test verifying pagination metadata (
count,next,previous) is correct across two pages. - Add a test verifying throttling returns 429 after exceeding the configured rate.
- Add a test verifying the
searchfilter returns only matching books.
Quiz
- True/False:
force_authenticate()performs a real password-based login. (False — it bypasses auth for test speed) - What DRF test class extends Django's
Clientfor API-aware requests? (APIClient) - Why should you write tests for both authorized and unauthorized access? (To catch broken/missing permission enforcement)
- What's a common tool for generating realistic test fixtures at scale? (
factory_boy)
Chapter 12: Production — Deployment, Security, Performance & Documentation
Theory
Shipping a DRF API to production adds concerns beyond local development: process management (WSGI/ASGI servers), reverse proxying, static file serving for the browsable API, structured logging, monitoring, API documentation (OpenAPI/Swagger), and hardened security settings. A typical production stack:
Client → CDN/Load Balancer → Nginx (TLS termination, static files) → Gunicorn/Uvicorn (WSGI/ASGI workers) → Django/DRF → PostgreSQL + Redis
Internal Working
- Gunicorn/uWSGI spawn multiple worker processes, each an independent Python interpreter running your Django/DRF app — this is why shared state (like local-memory throttle caches) fails across workers, requiring Redis/Memcached.
- Nginx terminates TLS, serves static/media files directly (bypassing Django for performance), and proxies dynamic requests to Gunicorn via a Unix socket or local port.
- DRF's
DEFAULT_RENDERER_CLASSESrestricted toJSONRendereravoids shipping/serving the browsable API's HTML/CSS/JS in production, reducing attack surface and payload size. - OpenAPI schema generation (
drf-spectacular) introspects your serializers/views/viewsets at request time (or build time) to produce a machine-readable schema, which Swagger UI/Redoc render into interactive docs.
Diagrams
┌─────────────┐
Internet ──────►│ Nginx │ TLS termination, static/media, gzip
└──────┬──────┘
│ proxy_pass (unix socket / 127.0.0.1:8000)
┌──────▼──────┐
│ Gunicorn │ N worker processes (sync/gevent/uvicorn workers)
└──────┬──────┘
│
┌──────▼──────┐ ┌───────────────┐
│ Django + DRF │ ─────► │ PostgreSQL │
└──────┬──────┘ └───────────────┘
│
┌──────▼──────┐
│ Redis │ cache, throttling, celery broker, sessions
└─────────────┘
Commands
# Production dependencies
pip install gunicorn psycopg2-binary django-redis drf-spectacular
# Collect static assets (needed for browsable API CSS/JS if enabled, and admin)
python manage.py collectstatic --noinput
# Run with Gunicorn (sync workers, tune count ~ 2*CPU+1)
gunicorn config.wsgi:application --workers 4 --bind 0.0.0.0:8000
# Check for common production misconfigurations
python manage.py check --deploy
# Generate OpenAPI schema
python manage.py spectacular --file schema.yaml
Code Examples
# settings/production.py
DEBUG = False
ALLOWED_HOSTS = ['api.example.com']
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = 'DENY'
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer', # disable BrowsableAPIRenderer
],
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.UserRateThrottle',
'rest_framework.throttling.AnonRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {'user': '1000/day', 'anon': '100/day'},
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
'EXCEPTION_HANDLER': 'api.exceptions.custom_exception_handler',
}
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://redis:6379/1',
}
}
# api/exceptions.py — custom exception handler to avoid leaking internals
from rest_framework.views import exception_handler
import logging
logger = logging.getLogger('api')
def custom_exception_handler(exc, context):
response = exception_handler(exc, context)
if response is not None:
logger.warning("API error: %s | context=%s", exc, context['view'])
return response
# urls.py — OpenAPI/Swagger docs
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
urlpatterns += [
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
]
# Performance: avoiding N+1 queries in production
class BookViewSet(viewsets.ModelViewSet):
queryset = Book.objects.select_related('author').prefetch_related('tags').all()
Real-World Examples
- A startup deploys DRF behind Nginx + Gunicorn on Docker/Kubernetes, using Redis for both caching and Celery task queues (e.g., sending emails asynchronously after
perform_create()). - A public API publishes interactive Swagger docs at
/api/docs/so third-party developers can explore endpoints without reading separate documentation.
Production Examples
- Companies running Django/DRF at scale typically add APM tooling (Sentry, Datadog, New Relic) to catch 500 errors and slow endpoints in real time, plus structured JSON logging shipped to a log aggregator (ELK/Loki).
- Blue-green or rolling deployments run
python manage.py migrateas a separate pre-deploy step (not on every worker boot) to avoid race conditions from multiple workers migrating simultaneously. - High-scale APIs put a CDN + edge cache in front of cacheable, non-personalized GET endpoints, using DRF's
@method_decorator(cache_page(...))or full HTTP caching headers (ETag,Cache-Control).
Common Mistakes
- Deploying with
DEBUG=True, leaking stack traces and settings via DRF's error responses. - Leaving the browsable API's default renderer enabled in production for all endpoints, unnecessarily increasing payload size and exposing internal form/schema details.
- Not running
select_related/prefetch_related, causing N+1 queries that only appear under production-scale data volumes. - No centralized logging/error tracking, making production incidents nearly impossible to diagnose quickly.
- Running Django's dev server (
runserver) in production instead of Gunicorn/uWSGI + Nginx.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| 500 errors with no detail in logs | Missing centralized logging/Sentry integration | Add Sentry SDK / structured logging with EXCEPTION_HANDLER |
| API very slow under real traffic (fine locally) | N+1 queries, missing indexes, no caching | Add select_related/prefetch_related, DB indexes, Redis caching |
| Static/browsable API assets 404 in production | collectstatic not run / Nginx not serving STATIC_ROOT |
Run collectstatic, configure Nginx location /static/ |
| Random throttle/session inconsistency across requests | Multiple workers with LocMemCache |
Move to Redis/Memcached shared backend |
| CORS errors from frontend | Missing/misconfigured django-cors-headers |
Install and configure CORS_ALLOWED_ORIGINS |
Security Notes
- Always run
python manage.py check --deploybefore shipping — it flags missing HTTPS/cookie-security settings. - Set
DEBUG = False, restrictALLOWED_HOSTS, enableSECURE_*cookie/HSTS settings, and disable the browsable API renderer for public production APIs. - Use a custom
EXCEPTION_HANDLERto ensure internal exception details (stack traces, SQL errors) never reach API consumers — log them server-side instead. - Keep dependencies patched (DRF, Django, JWT libraries) — API security vulnerabilities are frequently patched upstream; track CVEs for
djangorestframeworkandsimplejwt. - Enforce HTTPS everywhere; never allow tokens or credentials over plain HTTP.
- Apply the principle of least privilege to database users and API keys used by the app in production infrastructure.
Interview Questions
- Describe a typical production deployment architecture for a DRF application.
- Why is Redis often required in production even if you don't "need caching" per se?
- What does
python manage.py check --deployhelp catch? - How would you prevent leaking stack traces to API clients in production?
- How do you generate and serve interactive API documentation for a DRF project?
- What are common performance bottlenecks in DRF apps at scale, and how do you address them?
Hands-on Lab
Containerize the Book API with Docker (Django + Gunicorn + Nginx + Postgres + Redis via docker-compose), set DEBUG=False, disable the browsable API, add drf-spectacular for Swagger docs at /api/docs/, and verify python manage.py check --deploy passes cleanly.
Exercises
- Add Sentry (or a mocked equivalent) to capture and report unhandled exceptions.
- Add
select_related/prefetch_relatedtoBookViewSet.get_queryset()and verify query count reduction using Django Debug Toolbar ordjango.db.connection.queries. - Configure
django-cors-headersto allow a specific frontend origin only. - Set up
drf-spectacularand generate a staticschema.yaml, then serve it via Swagger UI.
Quiz
- True/False:
DEBUG=Trueis safe in production as long asALLOWED_HOSTSis set. (False) - What command flags common production security misconfigurations? (
python manage.py check --deploy) - Why must the cache backend be shared (e.g., Redis) rather than local memory in a multi-worker deployment? (Each worker process has isolated local memory; shared state like throttling breaks otherwise)
- Name two
SECURE_*Django settings relevant to HTTPS enforcement. (SECURE_SSL_REDIRECT,SECURE_HSTS_SECONDS,SESSION_COOKIE_SECURE, etc.) - What tool/package is commonly used to auto-generate OpenAPI schemas for a DRF project? (
drf-spectacular)
Appendix: Quick Reference Cheat Sheet
# Common imports
from rest_framework import serializers, viewsets, generics, permissions, status
from rest_framework.decorators import api_view, action, permission_classes
from rest_framework.response import Response
from rest_framework.views import APIView
# Common settings block
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [...],
'DEFAULT_PERMISSION_CLASSES': [...],
'DEFAULT_FILTER_BACKENDS': [...],
'DEFAULT_PAGINATION_CLASS': '...',
'PAGE_SIZE': 10,
'DEFAULT_THROTTLE_CLASSES': [...],
'DEFAULT_THROTTLE_RATES': {'anon': '20/min', 'user': '100/min'},
'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer'],
}
End of Notes. Suggested next steps: build a full project combining every chapter (JWT auth + permissions + throttling + pagination + filtering + tests + Dockerized production deployment) to cement the concepts end-to-end.