Django REST Framework
1 · The lesson
readDjango on its own is very good at server-rendered pages and the admin. For JSON APIs (the kind your React SPA, your mobile app or your other services use), the standard choice is Django REST Framework (DRF). It is a third-party package that turns Django models into a REST API. It gives you serializers, viewsets, routers, permissions, pagination, throttling and OpenAPI schema generation, all added to the framework you already use.
This lesson covers the four pieces that make up almost every DRF API: serializers, views/viewsets, permissions and pagination. It also answers the question "when should 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 usual way to add a REST API to a Django project. It expects that you already have models, sessions, auth and an ORM set up, and it 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 to a different framework.
- 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), which are all mature.
Pick FastAPI instead when:
- The project is new, API-only, and doesn't need Django's admin or ORM.
- Async I/O is central to the app: DRF is mainly synchronous, while FastAPI is async-native.
- You want Pydantic everywhere and the minimum framework footprint.
The full decision tree is in web-which-framework.
2. Serializers: Like Pydantic, but for Django Models
Serializers convert data in DRF: Django model instance ↔ JSON (and JSON ↔ validated Python dict for incoming requests). The closest thing in FastAPI is a Pydantic model. Both validate data, but DRF's serializers can also create and update model instances.
In most cases, you use 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", "created_at"]
What's happening:
ModelSerializerinspects the model and generates the right field types automatically.fieldsis the explicit allow-list of what's exposed. Use this. Never usefields = "__all__"on a user-facing model: if someone adds a sensitive field to the model later, the API exposes it without any warning.read_only_fieldsblocks writes: clients can read these fields but can't change them.source="author.username"is the dotted path for derived/related values, and 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 steps:
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 simple and correct option. DRF turns 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 one-off endpoints. For full CRUD on a resource, ViewSets need much less code.
4. ViewSets and Routers: The DRY Approach
A ModelViewSet puts the whole list/retrieve/create/update/partial_update/destroy CRUD set into one class. A Router connects 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/ changes the status. With custom actions, you can add endpoints that don't fit strict REST rules, and still keep them in the viewset.
For finer control: ModelViewSet → all actions. ReadOnlyModelViewSet → list + retrieve only. Or combine mixins (ListModelMixin, CreateModelMixin, GenericViewSet) to get exactly the actions 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, and permissions check on every request.
Built-in permission classes:
AllowAny: anyone can access. Use for public read-only endpoints.IsAuthenticated: logged-in users only.IsAdminUser: only users withuser.is_staff.IsAuthenticatedOrReadOnly: anyone can GET, but only logged-in users can write.
Set a global default in settings.py (recommended, because then every view is secure by default) and override it 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), and has_object_permission is the per-object check (runs after get_object()). A custom permission class is better than repeating if request.user != article.author: raise PermissionDenied in 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 becomes slow or crashes. 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 a large "infinite scroll" feed. It doesn't have to count all rows, and items don't move between pages 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 a very common DRF performance bug. Set the default once in settings.py, and you won't ship a list endpoint without pagination 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 request /api/articles/?status=published&author=4&search=django&ordering=-views, and you didn't write the code for any of those filters. For complex filtering rules, define a FilterSet class.
8. The Browsable API
This is DRF's most useful feature during development: every API endpoint also renders an HTML page when you open it in a browser. You see the JSON response, a form to POST new data, and links to related resources. All of it is 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 show an API to a colleague or a tester, and you don't need to set up Postman. Disable it in production by removing BrowsableAPIRenderer from DEFAULT_RENDERER_CLASSES.
9. Throttling
Throttling stops one client from sending too many requests to 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, each process counts separately. So with 4 gunicorn workers, a user can get up to 4× their quota. Use Redis or memcached in production.
10. Versioning
Once an API is public, if you break it without changing the version number, your users stop trusting it. DRF supports URL versioning (/api/v1/articles/), accept-header versioning, and namespace versioning. URL versioning is the easiest to understand:
# 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. Choose the behaviour based on it, or use completely different serializers/views for v2.
11. Schema and Docs: drf-spectacular
FastAPI's automatic OpenAPI docs are its main feature. DRF's equivalent 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. It needs more setup than FastAPI's, but with annotations (@extend_schema(...)) you can get docs of the same quality.
12. Testing
DRF includes 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 skips the login step, because here you're testing the view, not the login. For end-to-end tests, call the real /login/ endpoint and use the session or token it returns.
13. When to Use DRF vs FastAPI
A practical comparison for an API-only project:
| Concern | Django + DRF | FastAPI |
|---|---|---|
| Setup time on greenfield | Longer (Django project setup) | Shorter (one file) |
| Async support | Partial (DRF is mainly synchronous) | Native |
| OpenAPI docs | drf-spectacular (good) | Built-in (excellent) |
| Validation | Serializers | Pydantic |
| ORM | Django ORM (great) | Bring your own (SQLAlchemy) |
| Admin UI | Built in | None |
| Auth | Built-in user model + DRF auth classes | Bring your own |
| Ecosystem age | Older (since 2011), very mature | Newer (since 2018), growing fast |
| Best fit | Django monolith with API endpoints, internal tools, content sites | API-only services, async-heavy, microservices |
Both are very good. The choice usually depends on what else you need. If you need an admin UI, server-rendered pages or an existing Django codebase, pick DRF. If you need none of these, pick FastAPI.
14. Common Mistakes
1. Returning model instances directly without serializing. return Response(article) will fail or expose attributes you didn't mean to send. Always pass through a serializer: Response(ArticleSerializer(article).data).
2. fields = "__all__". It is convenient, until you add a secret_token field to the model and publish it by accident. Use explicit allow-lists.
3. No pagination on list endpoints. It works fine in dev with 10 rows, but fails in production with 100,000. Set the default in settings.py so that every list endpoint is paginated.
4. N+1 in viewset listings. This is the same problem as in 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 is repeated everywhere. Put the check in one BasePermission subclass and assign it once.
6. Mixing session-auth and token-auth without thinking about CSRF. SessionAuthentication enforces CSRF, but 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 saves a lot of time. Leave it on locally, and disable it in 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 on the server 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 created once, at import time, so it has no 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, and this is how you stop a client from setting owner themselves.
Show full solution
# tasks/models.py (add an 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 (the new lines) 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 and you get the browsable API. Log in via the admin first to get a session cookie. POST a task. Send a POST to /api/tasks/1/complete/ to set its done flag. The list endpoint paginates after 10 entries (?page=2), and users only see their own tasks. A client can never set owner, because owner isn't in the serializer fields, and perform_create sets it anyway.
That's a real API with sensible security defaults. The serializer, the viewset and the router together are about 40 lines of code.
What You Learned
- DRF is the standard 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 in plain Django.- The browsable API is DRF's most useful feature during development. Leave it on in dev, off in prod.
- Versioning via URL is the simplest pattern, and
drf-spectacularproduces OpenAPI schema and Swagger UI. - DRF vs FastAPI: pick Django+DRF if you have other reasons to use Django or need the admin. Pick FastAPI for new, API-only services with a lot of async work.
Next: Which Framework, When: a fair comparison of the frameworks, and a practical decision tree.
Found this useful? Share it with a friend who’s learning Python.