PythonMastery
intermediate 24 min read · lesson 11 of 12 in Web Frameworks

Django REST Framework

1 · The lesson

read

Django on its own is fantastic at server-rendered pages and the admin. For JSON APIs — the kind your React SPA, your mobile app, or your microservice neighbours consume — the canonical answer is Django REST Framework (DRF). It's the third-party package that turns Django models into a REST API with serializers, viewsets, routers, permissions, pagination, throttling, and OpenAPI schema generation, all bolted onto the framework you already use.

This lesson covers the four pieces that make up almost every DRF API: serializers, views/viewsets, permissions, and pagination — plus the honest answer to "when do I pick Django+DRF over FastAPI?".

Run locally with pip install django and python manage.py runserver after pip install djangorestframework. Browse the live API at /api/. DRF imports fine in the browser but has no port to listen on, so the API itself has to run on your machine.


1. Why DRF (And When Not To)

DRF is the de-facto way to add a REST API to a Django project. It assumes you have models, sessions, auth, and an ORM already wired — and builds the API layer on top with sensible defaults.

bash
pip install djangorestframework
python
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "blog",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 25,
}

Pick DRF when:

  • You already have a Django app and want to expose its models as an API without changing stacks.
  • You want one codebase serving both server-rendered pages (admin, internal dashboards) and JSON endpoints.
  • You need DRF's permissions, browsable API, and ecosystem (django-filter, drf-spectacular, dj-rest-auth) — all of which are mature.

Pick FastAPI instead when:

  • Greenfield, API-only, no need for Django's admin or ORM.
  • Async I/O is core — DRF is sync at its heart, FastAPI is async-native.
  • You want Pydantic everywhere and the minimum framework footprint.

The decision tree in full lives in web-which-framework.


2. Serializers — Pydantic-ish, but for Django Models

Serializers are DRF's translation layer: Django model instance ↔ JSON (and JSON ↔ validated Python dict for incoming requests). The closest analogue in the FastAPI world is a Pydantic model — but DRF's serializers also handle validation, model creation, and update.

The 80% case is ModelSerializer:

python
# blog/serializers.py
from rest_framework import serializers
from .models import Article

class ArticleSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.username", read_only=True)

    class Meta:
        model = Article
        fields = ["id", "title", "slug", "body", "status", "author", "author_name", "created_at"]
        read_only_fields = ["id", "slug", "created_at"]

What's happening:

  • ModelSerializer introspects the model and generates the right field types automatically.
  • fields is the explicit allow-list of what's exposed. Use this; never fields = "__all__" on a user-facing model — one new sensitive field on the model and it's silently exposed via the API.
  • read_only_fields blocks writes — clients can read these but can't change them.
  • source="author.username" is the dotted path for derived/related values; the field shows up as author_name in JSON.

For data that doesn't map to a model (a search request, a report response), use the plain Serializer:

python
class TaskSearchSerializer(serializers.Serializer):
    query = serializers.CharField(max_length=200)
    status = serializers.ChoiceField(choices=["all", "done", "open"], default="all")
    limit = serializers.IntegerField(min_value=1, max_value=100, default=20)
+ setup added so this can run · defines serializers
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

serializers = _AutoMock('serializers')

Validate any payload with the same two-step dance:

python
serializer = ArticleSerializer(data=request.data)
serializer.is_valid(raise_exception=True)        # 400 with field errors if invalid
article = serializer.save()                       # creates the model instance
+ setup added so this can run · defines ArticleSerializer, request
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def ArticleSerializer(*_a, **_kw):
    print('-> ArticleSerializer() called')
    return _AutoMock('ArticleSerializer()')
request = _AutoMock('request')

raise_exception=True is the lazy-but-correct option — DRF translates validation errors into a 400 response with structured {"field": ["message"]} JSON.


3. Function-Based API Views — @api_view

The simplest DRF view: a function with the @api_view decorator and DRF's Response (which handles content negotiation — JSON, browsable HTML, etc.).

python
# blog/views.py
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework import status
from .models import Article
from .serializers import ArticleSerializer

@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def article_list(request):
    if request.method == "GET":
        qs = Article.objects.select_related("author").order_by("-created_at")
        return Response(ArticleSerializer(qs, many=True).data)

    serializer = ArticleSerializer(data=request.data)
    serializer.is_valid(raise_exception=True)
    serializer.save(author=request.user)
    return Response(serializer.data, status=status.HTTP_201_CREATED)

@api_view is fine for ad-hoc endpoints. For full CRUD on a resource, ViewSets are dramatically less code.


4. ViewSets and Routers — The DRY Approach

A ModelViewSet packs the whole list/retrieve/create/update/partial_update/destroy CRUD set into one class. A Router wires it to URLs automatically.

python
# blog/views.py
from rest_framework import viewsets
from .models import Article
from .serializers import ArticleSerializer

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.select_related("author").order_by("-created_at")
    serializer_class = ArticleSerializer

    def perform_create(self, serializer):
        serializer.save(author=self.request.user)
python
# blog/urls.py
from rest_framework.routers import DefaultRouter
from .views import ArticleViewSet

router = DefaultRouter()
router.register("articles", ArticleViewSet, basename="article")

urlpatterns = router.urls
python
# mysite/urls.py
urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("blog.urls")),
]
+ setup added so this can run · defines path, include, admin
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')
def include(*_a, **_kw):
    print('-> include() called')
    return _AutoMock('include()')
admin = _AutoMock('admin')

What you get from those ~10 lines:

MethodURLAction
GET/api/articles/list
POST/api/articles/create
GET/api/articles/{id}/retrieve
PUT/api/articles/{id}/full update
PATCH/api/articles/{id}/partial update
DELETE/api/articles/{id}/destroy

For custom actions (anything outside the standard CRUD set), decorate a method with @action:

python
from rest_framework.decorators import action

class ArticleViewSet(viewsets.ModelViewSet):
    # ...

    @action(detail=True, methods=["post"])
    def publish(self, request, pk=None):
        article = self.get_object()
        article.status = "published"
        article.save(update_fields=["status"])
        return Response({"status": article.status})
+ setup added so this can run · defines viewsets, Response
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
def Response(*_a, **_kw):
    print('-> Response() called')
    return _AutoMock('Response()')

Now POST /api/articles/42/publish/ flips the status. Custom actions are how you escape the pure-REST straitjacket without abandoning the viewset.

For finer control: ModelViewSet → all actions; ReadOnlyModelViewSet → list + retrieve only; or compose mixins (ListModelMixin, CreateModelMixin, GenericViewSet) for exactly the subset you want.


5. Permissions — Who Can Do What

DRF separates authentication (who are you?) from permissions (are you allowed to do this?). Authentication runs first; permissions check on every request.

Built-in permission classes:

  • AllowAny — wide open. Use for public read-only endpoints.
  • IsAuthenticated — logged-in users only.
  • IsAdminUser — user.is_staff only.
  • IsAuthenticatedOrReadOnly — anyone can GET, only authenticated can write.

Apply globally in settings.py (recommended default — secure by default) and override per-view:

python
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    permission_classes = [permissions.IsAuthenticatedOrReadOnly]
+ setup added so this can run · defines viewsets, ArticleSerializer, permissions, Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
ArticleSerializer = _AutoMock('ArticleSerializer')
permissions = _AutoMock('permissions')
Article = _AutoMock('Article')

For "author can edit their own article, others can read", write a BasePermission subclass:

python
from rest_framework import permissions

class IsAuthorOrReadOnly(permissions.BasePermission):
    def has_object_permission(self, request, view, obj):
        if request.method in permissions.SAFE_METHODS:    # GET, HEAD, OPTIONS
            return True
        return obj.author == request.user

has_permission is the view-level check (runs before the object is fetched); has_object_permission is the per-object check (runs after get_object()). Custom permission classes beat scattering if request.user != article.author: raise PermissionDenied across every view.

For JWT-based auth (token in the Authorization: Bearer ... header), see auth-jwt. The DRF integration is djangorestframework-simplejwt.


6. Pagination

If your list endpoint returns 10,000 rows, your mobile client cries. Configure pagination globally:

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 25,
}

The three built-in styles:

ClassURL patternBest for
PageNumberPagination?page=2Most cases; familiar to clients
LimitOffsetPagination?limit=10&offset=20When clients want fine-grained windows
CursorPagination?cursor=cD0yMDI2L...Large datasets with stable ordering; no COUNT(*)

CursorPagination is what you want for an "infinite scroll" feed at scale — it doesn't have to count all rows, and there's no "page drift" when new rows arrive.

Response shape (PageNumberPagination):

json
{
  "count": 142,
  "next": "/api/articles/?page=3",
  "previous": "/api/articles/?page=1",
  "results": [ /* 25 articles */ ]
}

Forgetting to paginate is the most common DRF performance bug in the wild — set the default once in settings.py and you'll never ship an unpaginated list endpoint by accident.


7. Filtering, Searching, Ordering

Combine DRF with django-filter and SearchFilter for declarative query-param filtering:

bash
pip install django-filter
python
# settings.py
INSTALLED_APPS = [..., "django_filters"]

REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}
python
class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    filterset_fields = ["status", "author"]
    search_fields = ["title", "body"]
    ordering_fields = ["created_at", "views"]
    ordering = ["-created_at"]
+ setup added so this can run · defines viewsets, ArticleSerializer, Article
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

viewsets = _AutoMock('viewsets')
ArticleSerializer = _AutoMock('ArticleSerializer')
Article = _AutoMock('Article')

A client can now hit /api/articles/?status=published&author=4&search=django&ordering=-views and you didn't write any of that. For complex filtering rules, define a FilterSet class.


8. The Browsable API

DRF's killer development feature: every API endpoint also renders an HTML page when you hit it in a browser. You see the JSON response, a form to POST new data, and links to navigate the resource graph — all generated from your serializer.

python
http://localhost:8000/api/articles/

In a real browser you get a styled page with a GET tab, a POST form (validated against the serializer), and authentication. It's the fastest way to demo an API to a colleague or QA — no Postman setup required. Disable it in production by removing BrowsableAPIRenderer from DEFAULT_RENDERER_CLASSES.


9. Throttling

Stop one client from hammering your API. Throttle classes count requests per time window per identity (anon by IP, auth by user).

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": [
        "rest_framework.throttling.AnonRateThrottle",
        "rest_framework.throttling.UserRateThrottle",
    ],
    "DEFAULT_THROTTLE_RATES": {
        "anon": "20/min",
        "user": "1000/hour",
    },
}

For per-view throttles, set throttle_classes on the view. For per-endpoint limits (100/hour on the expensive report endpoint), subclass UserRateThrottle with a custom scope.

Production note: the default throttle backend uses Django's cache framework. With the in-memory cache you'll throttle per-process — which means across 4 gunicorn workers, the user gets 4× their quota. Use Redis or memcached in production.


10. Versioning

Once an API ships, breaking it without a version bump is how you lose customer trust. DRF supports URL versioning (/api/v1/articles/), accept-header versioning, and namespace versioning. URL is the easiest to reason about:

python
# settings.py
REST_FRAMEWORK = {
    "DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
    "ALLOWED_VERSIONS": ["v1", "v2"],
}

# urls.py
urlpatterns = [
    path("api/<version>/", include("blog.urls")),
]
+ setup added so this can run · defines path, include
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')
def include(*_a, **_kw):
    print('-> include() called')
    return _AutoMock('include()')

request.version is then "v1" or "v2" inside the view — branch behaviour from there, or use entirely different serializers/views for v2.


11. Schema and Docs — drf-spectacular

FastAPI's auto-OpenAPI is its killer feature. DRF's answer is drf-spectacular:

bash
pip install drf-spectacular
python
# settings.py
INSTALLED_APPS = [..., "drf_spectacular"]
REST_FRAMEWORK = {
    "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
}
SPECTACULAR_SETTINGS = {"TITLE": "Blog API", "VERSION": "1.0.0"}
python
# urls.py
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="docs"),
]
+ setup added so this can run · defines urlpatterns, path
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

urlpatterns = 1
def path(*_a, **_kw):
    print('-> path() called')
    return _AutoMock('path()')

Visit /api/docs/ and you get a Swagger UI generated from your viewsets and serializers. Not as zero-config as FastAPI's, but with annotations (@extend_schema(...)) you can match the polish.


12. Testing

DRF ships APIClient — a thin wrapper around Django's test client that handles JSON, auth, and content types properly.

python
from rest_framework.test import APITestCase
from django.contrib.auth.models import User

class ArticleAPITests(APITestCase):
    def setUp(self):
        self.user = User.objects.create_user(username="surya", password="x")
        self.client.force_authenticate(user=self.user)

    def test_create_article(self):
        response = self.client.post("/api/articles/", {
            "title": "Hello",
            "slug": "hello",
            "body": "Body text",
            "status": "draft",
            "author": self.user.id,
        }, format="json")
        self.assertEqual(response.status_code, 201)
        self.assertEqual(response.data["title"], "Hello")

force_authenticate bypasses the auth flow — you're testing the view, not the login path. For end-to-end tests, hit the actual /login/ endpoint and grab the session/token.


13. When to Use DRF vs FastAPI

A pragmatic comparison for the API-only case:

ConcernDjango + DRFFastAPI
Setup time on greenfieldHigher (Django scaffold)Lower (one file)
Async supportPartial (DRF sync at heart)Native
OpenAPI docsdrf-spectacular (good)Built-in (excellent)
ValidationSerializersPydantic
ORMDjango ORM (great)Bring your own (SQLAlchemy)
Admin UIFreeNone
AuthBuilt-in user model + DRF auth classesBring your own
Ecosystem age12+ years, very mature6 years, growing fast
Best fitDjango monolith with API endpoints, internal tools, content sitesAPI-only services, async-heavy, microservices

Both are excellent. The choice is usually decided by what else you need: admin UI, server-rendered pages, an existing Django codebase → DRF. None of that → FastAPI.


14. Common Mistakes

1. Returning model instances directly without serializing. return Response(article) will fail or leak attributes. Always pass through a serializer: Response(ArticleSerializer(article).data).

2. fields = "__all__". Convenient until you add a secret_token field to the model and accidentally publish it. Use explicit allow-lists.

3. No pagination on list endpoints. Works fine in dev with 10 rows; falls over in prod with 100,000. Set the default in settings.py and you're protected by default.

4. N+1 in viewset listings. Same trap as the previous lesson — apply select_related / prefetch_related to the queryset on the viewset.

5. Writing permission checks inline in every view. if request.user != article.author: raise PermissionDenied repeated everywhere. Centralise it in a BasePermission subclass and assign once.

6. Mixing session-auth and token-auth without thinking about CSRF. SessionAuthentication enforces CSRF; TokenAuthentication doesn't. If you allow both, browser clients still need to send CSRF tokens for session-auth POSTs.

7. Trusting the client on author. If the client can set author=42 in the request body, they can post as someone else. Always set the owner server-side in perform_create: serializer.save(author=self.request.user).

8. Disabling the browsable API in dev. It's an enormous productivity boost — leave it on locally, disable it on production.


🎯 Your Turn — A Tasks REST API

Build a JSON API on top of the tasks app from the previous lessons. Requirements:

1. Install DRF, add "rest_framework" to INSTALLED_APPS.
2. Set DRF defaults: IsAuthenticated permission, PageNumberPagination, PAGE_SIZE=10.
3. Write a TaskSerializer (ModelSerializer) exposing id, title, done, created_at, tags. Make id and created_at read-only.
4. Build a TaskViewSet(viewsets.ModelViewSet) with full CRUD. The queryset should eager-load tags.
5. Register the viewset at /api/tasks/ via a DefaultRouter.
6. Add a custom @action for POST /api/tasks/{id}/complete/ that flips done=True.
7. Each task should belong to the user who creates it — set this server-side in perform_create.

(For step 7 you'll need to add an owner = ForeignKey(User, on_delete=CASCADE) field to Task and migrate. Filter the viewset's queryset to Task.objects.filter(owner=self.request.user) so users only see their own tasks.)

Skeleton:

python
# tasks/serializers.py
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = [...]            # TODO 1
        read_only_fields = [...]   # TODO 2
python
# tasks/views.py
from rest_framework import viewsets, permissions
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Task
from .serializers import TaskSerializer

class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        # TODO 3: filter to self.request.user's tasks, prefetch tags
        ...

    def perform_create(self, serializer):
        # TODO 4: save with owner=self.request.user
        ...

    @action(detail=True, methods=["post"])
    def complete(self, request, pk=None):
        # TODO 5: fetch the task via self.get_object(), set done=True, save, return serialized
        ...
python
# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet

router = DefaultRouter()
# TODO 6: register

urlpatterns = router.urls
Hint 1 — get_queryset vs queryset When the queryset depends on the request (e.g. filtering by self.request.user), override get_queryset(self) instead of setting the class-level queryset attribute — the class attribute is evaluated once at import time and won't have access to the request.
Hint 2 — perform_create perform_create receives the already-validated serializer. Call serializer.save(owner=self.request.user) — any kwargs you pass to save() override values in the validated data, which is exactly how you stop a client from setting owner themselves.
Show full solution
python
# tasks/models.py — add owner field
from django.conf import settings
from django.db import models

class Task(models.Model):
    title = models.CharField(max_length=200)
    done = models.BooleanField(default=False)
    created_at = models.DateTimeField(auto_now_add=True)
    tags = models.ManyToManyField("Tag", blank=True, related_name="tasks")
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="tasks",
    )

    class Meta:
        ordering = ["-created_at"]

    def __str__(self):
        return self.title
python
# tasks/serializers.py
from rest_framework import serializers
from .models import Task

class TaskSerializer(serializers.ModelSerializer):
    class Meta:
        model = Task
        fields = ["id", "title", "done", "tags", "created_at"]
        read_only_fields = ["id", "created_at"]
python
# tasks/views.py
from rest_framework import viewsets, permissions, status
from rest_framework.decorators import action
from rest_framework.response import Response
from .models import Task
from .serializers import TaskSerializer

class TaskViewSet(viewsets.ModelViewSet):
    serializer_class = TaskSerializer
    permission_classes = [permissions.IsAuthenticated]

    def get_queryset(self):
        return (
            Task.objects
            .filter(owner=self.request.user)
            .prefetch_related("tags")
        )

    def perform_create(self, serializer):
        serializer.save(owner=self.request.user)

    @action(detail=True, methods=["post"])
    def complete(self, request, pk=None):
        task = self.get_object()                # 404 if not owned (queryset is filtered)
        task.done = True
        task.save(update_fields=["done"])
        return Response(self.get_serializer(task).data)
python
# tasks/urls.py
from rest_framework.routers import DefaultRouter
from .views import TaskViewSet

router = DefaultRouter()
router.register("tasks", TaskViewSet, basename="task")

urlpatterns = router.urls
python
# todo/settings.py — additions
INSTALLED_APPS = [..., "rest_framework", "tasks"]

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 10,
}
python
# todo/urls.py
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("api/", include("tasks.urls")),
]

Run the migration and try it out:

bash
python manage.py makemigrations tasks
python manage.py migrate
python manage.py createsuperuser            # for auth in the browsable API
python manage.py runserver

Visit /api/tasks/ in a browser — you get the browsable API. Log in via the admin first to get a session cookie. POST a task. Hit /api/tasks/1/complete/ to flip its done flag. List endpoint paginates after 10 entries (?page=2), users only see their own tasks, and there is no scenario where a client can set owner themselves because owner isn't in the serializer fields and perform_create overrides it.

That's a real, defensible API in under 50 lines of code.


What You Learned

  • DRF is the canonical way to expose Django models as JSON APIs. Add "rest_framework" to INSTALLED_APPS and configure defaults in REST_FRAMEWORK.
  • Serializers translate between models and JSON, plus validate incoming data. Use explicit fields allow-lists; never "__all__".
  • ViewSets + Routers give you full CRUD URLs from one class. @action decorates custom endpoints.
  • Permissions separate "are you logged in?" from "are you allowed to do this?". Centralise non-trivial rules in BasePermission subclasses.
  • Paginate every list endpoint. Set the default in settings.py so you never forget.
  • select_related / prefetch_related on the viewset's queryset — the same N+1 fix as plain Django.
  • The browsable API is DRF's developer-experience killer feature. Leave it on in dev, off in prod.
  • Versioning via URL is the simplest pattern; drf-spectacular produces OpenAPI schema and Swagger UI.
  • DRF vs FastAPI — Django+DRF if you have other Django reasons or need the admin; FastAPI on greenfield API-only async-heavy services.

Next: Which Framework, When — the honest cross-framework comparison and a decision tree you can actually use.