Django Rest Framework (DRF) Complete Learning Guide

@admin July 04, 2026

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

  1. Introduction & Internal Architecture
  2. Serializers
  3. ModelSerializers & Validation
  4. Views — APIView & Generic Views
  5. ViewSets & Routers
  6. Authentication
  7. Permissions
  8. Throttling & Rate Limiting
  9. Pagination
  10. Filtering, Searching & Ordering
  11. Testing DRF APIs
  12. 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:

  1. Django's URL resolver calls View.as_view() — DRF's APIView.as_view() wraps this and disables Django's CSRF check for session auth edge cases, deferring CSRF enforcement until after authentication.
  2. APIView.dispatch() runs (this is the heart of DRF):
    • Builds a DRF Request object wrapping Django's HttpRequest (lazy parsing of body via request.data).
    • Calls self.initial(request) → runs content negotiation, authentication, permission checks, throttle checks.
    • Looks up the handler method (get, post, etc.) matching request.method.lower().
    • Calls the handler, catches exceptions via self.handle_exception(), converts them to DRF Response objects 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's SimpleTemplateResponse machinery).
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 DRF APIView subclass 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_CLASSES restricted to JSONRenderer only (browsable API disabled) for performance and security.

Common Mistakes

  • Treating request.data like Django's request.POSTrequest.data handles JSON, form-data, and multipart uniformly; request.POST only handles form-encoded data.
  • Forgetting rest_framework in INSTALLED_APPSModuleNotFoundError or missing static files for the browsable API.
  • Mixing Django's HttpResponse with DRF views instead of rest_framework.response.Response, which breaks content negotiation.
  • Not setting DEFAULT_RENDERER_CLASSES explicitly 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 BrowsableAPIRenderer enabled in production for authenticated/sensitive endpoints — it exposes forms and schema info.
  • Always pair SessionAuthentication with Django's CSRF protection for browser clients; it's safe by default only when CSRF middleware is active.
  • Avoid DEBUG=True in production — DRF's exception handler will leak stack traces via API responses otherwise.

Interview Questions

  1. What problem does DRF solve on top of vanilla Django?
  2. Walk through what happens internally when a request hits an APIView.
  3. What's the difference between request.POST and request.data?
  4. How does DRF perform content negotiation?
  5. 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

  1. Create a HealthCheckView returning {"status": "ok", "version": "1.0"}.
  2. Add a POST handler to the same view that echoes back the JSON body sent by the client.
  3. Restrict the view's renderers to JSON only and verify the browsable API disappears.

Quiz

  1. (T/F) DRF replaces Django's URL routing system.
  2. Which method in APIView handles authentication, permissions, and throttling before the handler runs? (initial)
  3. What class-based view do virtually all DRF views ultimately inherit from? (APIView → Django's View)
  4. 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 Serializer is built from declared Field instances (CharField, IntegerField, etc.), each with to_representation() (object → primitive) and to_internal_value() (primitive → python, with validation).
  • Calling serializer.data triggers to_representation(instance) on the serializer, which loops through fields calling each field's get_attribute() then to_representation().
  • Calling serializer.is_valid() triggers to_internal_value(data), which loops fields calling run_validation() (field-level validate_<field> methods) then the serializer's own validate() for cross-field checks. Errors accumulate into serializer.errors as a dict.
  • .save() calls either create() or update() depending on whether an instance was 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 Product model into JSON with computed fields like discounted_price using SerializerMethodField.
  • 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 Movie model 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=True on sensitive fields like card_number so they're accepted on input but never echoed back in the response.

Common Mistakes

  • Forgetting read_only=True on fields like id or created_at, letting clients attempt to set them.
  • Doing validation logic inside views instead of serializers — breaks reusability and the single-responsibility principle.
  • Calling .data before .is_valid() on a bound serializer — raises AssertionError.
  • Mutating validated_data incorrectly (e.g., not popping nested data before Model.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_data for 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

  1. What's the difference between Serializer and ModelSerializer?
  2. Explain the to_representation vs to_internal_value lifecycle.
  3. Where would you put cross-field validation logic?
  4. What does write_only=True do, and when would you use it?
  5. How does .save() decide between calling create() vs update()?

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

  1. Add a SerializerMethodField called is_recent that returns True if published_date is within the last year.
  2. Add field-level validation ensuring title has no leading/trailing whitespace.
  3. Write a cross-field validator that rejects the object if price is 0 and title contains "Premium".

Quiz

  1. Which serializer method is responsible for cross-field validation? (validate())
  2. True/False: write_only fields appear in serializer.data. (False)
  3. What exception type should validation errors raise? (serializers.ValidationError)
  4. 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.CharFieldserializers.CharField, models.ForeignKeyPrimaryKeyRelatedField, 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 ModelSerializer for Book with a nested read-only Author and a write-only author_id for creation — a very common "read nested, write by ID" pattern.
  • A job board's Application serializer validates that resume_file.size doesn't exceed 5MB inside validate_resume_file().

Production Examples

  • Airbnb-style listing APIs use ModelSerializer with extra_kwargs to mark fields like host as read_only, auto-populated from request.user in the view's perform_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 ModelSerializer calls Model.full_clean() — it doesn't; model clean() 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 silent AttributeError swallowed as "field not found."
  • Using nested writable serializers without overriding create()/update() — DRF's ModelSerializer does 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 PrimaryKeyRelatedField for foreign keys, scope the queryset to what the requesting user is authorized to reference (e.g., only their own Author objects), otherwise users can attach records to unrelated foreign keys (an IDOR-style vulnerability).

Interview Questions

  1. Why doesn't ModelSerializer call the model's full_clean()?
  2. How would you implement nested writable serializers?
  3. What's the risk of using fields = '__all__'?
  4. How does ModelSerializer decide 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

  1. Add a UniqueTogetherValidator ensuring (title, author) pairs are unique.
  2. Add extra_kwargs to make price write-only for a specific "internal" serializer variant.
  3. Implement nested-write support so a POST can create both Author and Book in a single request.

Quiz

  1. True/False: ModelSerializer automatically calls the model's clean() method. (False)
  2. Which serializer field is auto-generated for a ForeignKey? (PrimaryKeyRelatedField)
  3. Where do you enforce "field A must be scoped to current user's own related objects"? (Restrict queryset on the PrimaryKeyRelatedField, or validate in validate()/view)
  4. What must you override to support nested writable serializers? (create() and update())

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/ uses ListCreateAPIView for listing/creating posts, and /api/posts/<slug>/ uses RetrieveUpdateDestroyAPIView with lookup_field = 'slug'.
  • An admin-only inventory API restricts perform_create() to auto-set warehouse=request.user.warehouse, preventing cross-tenant writes.

Production Examples

  • Large APIs (e.g., a logistics tracking platform) use select_related/prefetch_related inside get_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 call self.check_object_permissions(self.request, obj) combined with custom IsOwner permission 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 override create() entirely, duplicating DRF's boilerplate unnecessarily.
  • Using ListCreateAPIView when create should be restricted to admins — permission classes need per-action nuance (solved via get_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 inside get_object() — if you fetch objects manually, you must call this yourself.

Interview Questions

  1. What's the difference between a mixin and a generic view in DRF?
  2. How would you scope a queryset so users only see their own records?
  3. What hook would you use to auto-assign request.user on object creation?
  4. Why might you choose APIView over 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

  1. Add perform_update() to auto-update a last_modified_by field.
  2. Convert the function-based book_list view into a ListCreateAPIView, keeping identical behavior.
  3. Add a custom action for RetrieveUpdateDestroyAPIView denying DELETE unless request.user.is_staff.

Quiz

  1. Which mixin provides the .list() method? (ListModelMixin)
  2. True/False: get_object() automatically calls check_object_permissions(). (True)
  3. What generic view combines list + create? (ListCreateAPIView)
  4. 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 — like APIView, manual action methods.
  • ModelViewSet — combines GenericAPIView + all mixins → full CRUD with almost no code.
  • ReadOnlyModelViewSet — only list/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 EmployeeViewSet exposes standard CRUD plus a custom @action(detail=True) def promote(self, request, pk=None) endpoint at /employees/{id}/promote/.
  • A ride-sharing backend's TripViewSet has 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 + DefaultRouter because it keeps urls.py tiny 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.ModelViewSet subclass (e.g., AuditedModelViewSet) that all resource viewsets inherit from, centralizing audit-logging (perform_create/perform_update/perform_destroy overrides) across the entire API.

Common Mistakes

  • Forgetting basename when queryset is dynamically generated (no static .queryset attribute) — router can't infer it and raises an AssertionError.
  • Overusing @action for logic that should really be a separate resource/viewset (e.g., cramming unrelated business logic into one bloated viewset).
  • Not distinguishing detail=True vs detail=False correctly, 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 @action methods bypass none of DRF's authentication/permission pipeline — but if you add per-action permission logic, remember permission_classes on the viewset level applies to all actions unless overridden via get_permissions().
  • Be deliberate with detail=False actions that expose aggregate/sensitive data (e.g., bestsellers might leak business metrics) — scope permissions per action.

Interview Questions

  1. What's the difference between ViewSet and ModelViewSet?
  2. How does a Router map HTTP verbs to viewset actions internally?
  3. When would you use @action(detail=True) vs detail=False?
  4. 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

  1. Add get_permissions() to BookViewSet so only staff can destroy(), but anyone can list()/retrieve().
  2. Add a @action(detail=False) returning books grouped by author.
  3. Register a second ReadOnlyModelViewSet for Author in the same router.

Quiz

  1. True/False: ViewSet classes map HTTP verbs directly to method names like APIView. (False — they map to actions like list/create)
  2. What decorator adds a custom endpoint to a ViewSet? (@action)
  3. What router class produces both the resource routes and a browsable API root view? (DefaultRouter)
  4. What's needed if your viewset has no static queryset attribute? (basename passed to router.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/refresh tokens; the app stores them securely and sends Authorization: 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 JWTAuthentication for the API with short-lived access tokens (5–15 min) and rotating refresh tokens, with refresh-token blacklisting on logout (simplejwt's token_blacklist app).
  • 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 BasicAuthentication over plain HTTP (not HTTPS) — credentials sent effectively in plaintext (base64 is not encryption).
  • Storing JWTs in localStorage on web frontends (XSS-exploitable) instead of httpOnly secure cookies.
  • Not setting token expiry, or using TokenAuthentication's single non-expiring token in a long-lived production system.
  • Forgetting CSRF protection when combining SessionAuthentication with 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

  1. What's the difference between authentication and permissions in DRF?
  2. Walk through the JWT authentication flow end-to-end.
  3. Why is BasicAuthentication risky without HTTPS?
  4. How would you implement a custom API-key authentication scheme?
  5. 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

  1. Implement a custom APIKeyAuthentication class and apply it only to a specific viewset.
  2. Configure simplejwt access token lifetime to 5 minutes and refresh token lifetime to 1 day.
  3. Add token blacklisting on logout using rest_framework_simplejwt.token_blacklist.

Quiz

  1. True/False: Authentication decides what an authenticated user is allowed to do. (False — that's permissions)
  2. What method must a custom authentication class implement? (authenticate(self, request))
  3. What does returning None from authenticate() signal vs raising AuthenticationFailed? (None = try next class/anonymous; raise = immediate 401)
  4. 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 check request.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/retrieve open to all, create requires login, destroy requires admin — extremely common pattern in production ModelViewSets.
  • Enterprise systems integrate DjangoModelPermissions combined with Django's built-in auth app group/permission model to map REST actions to Django's add_*/change_*/delete_* permissions, integrating with existing admin-managed RBAC.

Common Mistakes

  • Assuming object-level permissions apply automatically to list() — they don't; unscoped get_queryset() combined with only object-level checks leaks other users' data in list views.
  • Forgetting permissions.SAFE_METHODS handling, accidentally blocking GET/HEAD/OPTIONS too.
  • Returning False from has_permission() without proper HTTP status differentiation — DRF returns 403 if user is authenticated, 401 if not, automatically, but only if IsAuthenticated/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

  1. Difference between has_permission() and has_object_permission()?
  2. Why doesn't object-level permission checking apply to list() views?
  3. How would you implement "only the owner or an admin can edit" logic?
  4. 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

  1. Implement a composite permission class allowing access if EITHER the user is the owner OR staff (OR logic).
  2. Add per-action permissions: list/retrieve open to anyone, create requires authentication, update/destroy requires ownership.
  3. Write a permission class enforcing a maximum of 5 books per non-staff user (checked in has_permission using request.user.book_set.count()).

Quiz

  1. True/False: Object-level permissions are checked automatically during list(). (False)
  2. Which built-in constant lists the "read-only" HTTP methods? (permissions.SAFE_METHODS)
  3. What method would you override on a ViewSet to vary permissions per action? (get_permissions())
  4. 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 + UserRateThrottle tiers.
  • A login endpoint uses ScopedRateThrottle limited 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's Throttled exception naturally sets Retry-After; teams often add custom middleware to add X-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 LocMemCache for 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

  1. What's the difference between permissions and throttling?
  2. Why is Redis preferred over local memory cache for throttling in production?
  3. How would you implement stricter throttling on a login endpoint specifically?
  4. 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

  1. Add a ScopedRateThrottle for a login endpoint limited to 5 requests/min.
  2. Implement a custom throttle that gives staff users unlimited requests (bypass throttling for is_staff).
  3. Switch the cache backend to Redis and verify throttling persists correctly across two separate manage.py runserver processes on different ports.

Quiz

  1. True/False: Throttling is a binary allow/deny check like permissions. (False — it's rate/time-windowed)
  2. What HTTP status code indicates throttling? (429)
  3. Why does LocMemCache fail for throttling in multi-process deployments? (Cache is process-local, not shared)
  4. 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 PageNumberPagination for simple "Page 1, 2, 3..." navigation in an admin dashboard.
  • A social-media feed uses CursorPagination ordered by -created_at so 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 OFFSET scans) and inconsistent at scale.
  • E-commerce search-result APIs typically use LimitOffsetPagination combined with search-engine-side pagination (Elasticsearch) rather than raw DB offset pagination for performance at high offsets.

Common Mistakes

  • Using PageNumberPagination/LimitOffsetPagination for 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=100000 and overload the server.
  • Forgetting ordering must be a stable, indexed field for CursorPagination — 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_size to 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 count for very large or sensitive tables (custom pagination class without a total count query, which also avoids a slow COUNT(*)).

Interview Questions

  1. Compare PageNumberPagination, LimitOffsetPagination, and CursorPagination — when would you choose each?
  2. Why does cursor pagination handle concurrent writes better than offset pagination?
  3. What's the performance downside of large OFFSET values in SQL?
  4. 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

  1. Implement a custom pagination class capping page_size at 50 with a default of 10.
  2. Add CursorPagination ordered by -published_date to a "recent books" endpoint.
  3. Modify pagination output to include a custom field, total_pages, in get_paginated_response().

Quiz

  1. Which pagination style is most resistant to "drift" caused by concurrent inserts/deletes? (CursorPagination)
  2. True/False: CursorPagination allows jumping directly to page 5. (False — cursors are sequential, not random-access)
  3. What setting limits the maximum page size a client can request? (max_page_size)
  4. 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_fields to 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-filter FilterSet classes, often combined with Elasticsearch/OpenSearch for full-text search_fields at scale instead of DB icontains (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 default icontains search on unindexed large text columns in production — slow full table scans; consider trigram/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__' or filterset_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

  1. How does filter_queryset() chain multiple filter backends together?
  2. What's the difference between SearchFilter and a DjangoFilterBackend exact-match filter?
  3. Why is ordering_fields = '__all__' a bad practice in production?
  4. 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

  1. Add a SearchFilter searching across title and author__name.
  2. Whitelist ordering_fields to only price and published_date.
  3. Add a custom FilterSet method filter for published_after using a custom filter_method.

Quiz

  1. What backend class enables ?search=term behavior? (SearchFilter)
  2. True/False: Filter backends run before pagination. (True)
  3. What's the risk of allowing ordering_fields = '__all__'? (Sorting on unindexed/sensitive fields, perf/security risk)
  4. What third-party package provides FilterSet classes 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 (or pytest) 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.py in CI, specifically requiring coverage on views.py/serializers.py/permissions.py.
  • Teams use factory_boy for 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 of force_authenticate(), drastically slowing the test suite.
  • Not isolating tests — relying on ordering or shared mutable state between test methods.
  • Forgetting format='json' on client.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

  1. What's the difference between force_authenticate() and a real login request in tests?
  2. Why does APITestCase wrap tests in DB transactions?
  3. What kinds of tests are most commonly missing in real-world API test suites?
  4. 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

  1. Add a test verifying pagination metadata (count, next, previous) is correct across two pages.
  2. Add a test verifying throttling returns 429 after exceeding the configured rate.
  3. Add a test verifying the search filter returns only matching books.

Quiz

  1. True/False: force_authenticate() performs a real password-based login. (False — it bypasses auth for test speed)
  2. What DRF test class extends Django's Client for API-aware requests? (APIClient)
  3. Why should you write tests for both authorized and unauthorized access? (To catch broken/missing permission enforcement)
  4. 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_CLASSES restricted to JSONRenderer avoids 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 migrate as 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 --deploy before shipping — it flags missing HTTPS/cookie-security settings.
  • Set DEBUG = False, restrict ALLOWED_HOSTS, enable SECURE_* cookie/HSTS settings, and disable the browsable API renderer for public production APIs.
  • Use a custom EXCEPTION_HANDLER to 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 djangorestframework and simplejwt.
  • 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

  1. Describe a typical production deployment architecture for a DRF application.
  2. Why is Redis often required in production even if you don't "need caching" per se?
  3. What does python manage.py check --deploy help catch?
  4. How would you prevent leaking stack traces to API clients in production?
  5. How do you generate and serve interactive API documentation for a DRF project?
  6. 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

  1. Add Sentry (or a mocked equivalent) to capture and report unhandled exceptions.
  2. Add select_related/prefetch_related to BookViewSet.get_queryset() and verify query count reduction using Django Debug Toolbar or django.db.connection.queries.
  3. Configure django-cors-headers to allow a specific frontend origin only.
  4. Set up drf-spectacular and generate a static schema.yaml, then serve it via Swagger UI.

Quiz

  1. True/False: DEBUG=True is safe in production as long as ALLOWED_HOSTS is set. (False)
  2. What command flags common production security misconfigurations? (python manage.py check --deploy)
  3. 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)
  4. Name two SECURE_* Django settings relevant to HTTPS enforcement. (SECURE_SSL_REDIRECT, SECURE_HSTS_SECONDS, SESSION_COOKIE_SECURE, etc.)
  5. 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.

0 Likes
30 Views
0 Comments

Filters

No filters available for this view.

Reset All