Django REST Framework
1 · The lesson
readDjango 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 djangoandpython manage.py runserverafterpip 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.
pip install djangorestframework
# 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:
# 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:
ModelSerializerintrospects the model and generates the right field types automatically.fieldsis the explicit allow-list of what's exposed. Use this; neverfields = "__all__"on a user-facing model — one new sensitive field on the model and it's silently exposed via the API.read_only_fieldsblocks 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 asauthor_namein JSON.
For data that doesn't map to a model (a search request, a report response), use the plain Serializer:
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:
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.).
# 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.
# 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)
# blog/urls.py from rest_framework.routers import DefaultRouter from .views import ArticleViewSet router = DefaultRouter() router.register("articles", ArticleViewSet, basename="article") urlpatterns = router.urls
# 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:
| Method | URL | Action |
|---|---|---|
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:
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_staffonly.IsAuthenticatedOrReadOnly— anyone can GET, only authenticated can write.
Apply globally in settings.py (recommended default — secure by default) and override per-view:
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:
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:
# settings.py REST_FRAMEWORK = { "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", "PAGE_SIZE": 25, }
The three built-in styles:
| Class | URL pattern | Best for |
|---|---|---|
PageNumberPagination | ?page=2 | Most cases; familiar to clients |
LimitOffsetPagination | ?limit=10&offset=20 | When 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):
{
"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:
pip install django-filter
# settings.py INSTALLED_APPS = [..., "django_filters"] REST_FRAMEWORK = { "DEFAULT_FILTER_BACKENDS": [ "django_filters.rest_framework.DjangoFilterBackend", "rest_framework.filters.SearchFilter", "rest_framework.filters.OrderingFilter", ], }
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.
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).
# 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:
# 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:
pip install drf-spectacular
# settings.py INSTALLED_APPS = [..., "drf_spectacular"] REST_FRAMEWORK = { "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", } SPECTACULAR_SETTINGS = {"TITLE": "Blog API", "VERSION": "1.0.0"}
# 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.
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:
| Concern | Django + DRF | FastAPI |
|---|---|---|
| Setup time on greenfield | Higher (Django scaffold) | Lower (one file) |
| Async support | Partial (DRF sync at heart) | Native |
| OpenAPI docs | drf-spectacular (good) | Built-in (excellent) |
| Validation | Serializers | Pydantic |
| ORM | Django ORM (great) | Bring your own (SQLAlchemy) |
| Admin UI | Free | None |
| Auth | Built-in user model + DRF auth classes | Bring your own |
| Ecosystem age | 12+ years, very mature | 6 years, growing fast |
| Best fit | Django monolith with API endpoints, internal tools, content sites | API-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:
# 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
# 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 ...
# 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 byself.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
# 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
# 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"]
# 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)
# tasks/urls.py from rest_framework.routers import DefaultRouter from .views import TaskViewSet router = DefaultRouter() router.register("tasks", TaskViewSet, basename="task") urlpatterns = router.urls
# 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, }
# 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:
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"toINSTALLED_APPSand configure defaults inREST_FRAMEWORK. - Serializers translate between models and JSON, plus validate incoming data. Use explicit
fieldsallow-lists; never"__all__". - ViewSets + Routers give you full CRUD URLs from one class.
@actiondecorates custom endpoints. - Permissions separate "are you logged in?" from "are you allowed to do this?". Centralise non-trivial rules in
BasePermissionsubclasses. - Paginate every list endpoint. Set the default in
settings.pyso you never forget. select_related/prefetch_relatedon 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-spectacularproduces 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.