Django Module 11 – APIs and modern front ends
REST APIs with Django REST framework
Build a JSON API with Django REST framework: serializers that validate, viewsets and routers, the right status codes, and pages, filters and ordering.
What you will learn
- Validate a request body with a ModelSerializer and return each field's errors
- Run the model's own rules in validate(), because a serializer never calls full_clean()
- Build list, create, retrieve, update and destroy endpoints from a viewset and a router
- Predict the status code an endpoint answers to each kind of request
- Filter, order and paginate a list with fields from an allowlist
Before you start
What you’ll learn · Before you start
What you will learn
- Validate a request body with a ModelSerializer and return each field's errors
- Run the model's own rules in validate(), because a serializer never calls full_clean()
- Build list, create, retrieve, update and destroy endpoints from a viewset and a router
- Predict the status code an endpoint answers to each kind of request
- Filter, order and paginate a list with fields from an allowlist
Before you start
On this page
The Nivas mobile app needs a resident’s bills, complaints and clubhouse bookings as JSON, and it sends new complaints back as JSON. A plain Django view for one such endpoint parses the body, checks every field, picks a status code and writes the JSON by hand, and one model needs five of them: list, create, read, change and delete.
Django REST framework (DRF), which 73 % of the 2026 Django Developers Survey’s respondents use for their APIs, turns that into a serializer and a viewset per model. This lesson builds the Nivas API with DRF 3.18.1 on Django 6.1.2: a serializer that says exactly what is wrong with a booking, the complaint endpoints, the status code of every answer, and pages, filters and ordering for 24 months of bills.
A serializer checks JSON field by field, then as a whole
A clubhouse booking arrives as {"flat": "B-1203", "starts_at": "2026-11-14T18:00:00Z", "ends_at": …, "guests": 8}.
A serializer: In Django REST framework, a class that turns model objects into data that becomes JSON, and checks the data of a request field by field and then as a whole, before save() creates or updates an object. does two jobs with it: it turns JSON from a request into checked Python
values, and it turns model objects back into data for a JSON response. A ModelSerializer builds its fields from the
model, so BookingSerializer lists five names and gets a datetime field for each time and a whole-number field with
the model’s limits for guests. Its flat is a SlugRelatedField, so the app sends the flat’s code rather than a
database id:
from django.core.exceptions import NON_FIELD_ERRORS
from django.core.exceptions import ValidationError as ModelValidationError
from rest_framework import serializers
from rest_framework.settings import api_settings
from amenities.models import Booking
from billing.models import Bill, Flat
from complaints.models import Complaint
class BillSerializer(serializers.ModelSerializer):
flat = serializers.SlugRelatedField(slug_field='code', read_only=True) # "B-1203" rather than a pk
class Meta:
model = Bill
fields = ['id', 'flat', 'month', 'amount_paise', 'paid']
class ResidentFlatField(serializers.SlugRelatedField):
"""A flat code, accepted only for a flat of the resident who sends it: the choices are an access rule too."""
def get_queryset(self):
return self.context['request'].user.flats.all()
class ComplaintSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='complaint-detail') # its value becomes the Location header
flat = ResidentFlatField(slug_field='code')
reporter = serializers.CharField(source='reporter.name', read_only=True)
class Meta:
model = Complaint
fields = ['url', 'id', 'flat', 'category', 'description', 'status', 'reporter']
read_only_fields = ['status'] # residents report; the committee resolves (not through this endpoint)
class BookingSerializer(serializers.ModelSerializer):
flat = serializers.SlugRelatedField(slug_field='code', queryset=Flat.objects.all())
class Meta:
model = Booking
fields = ['id', 'flat', 'starts_at', 'ends_at', 'guests']
def validate_starts_at(self, value):
"""Field-level: called with this one field's value, after the field's own checks."""
if value.minute not in (0, 30) or value.second or value.microsecond:
raise serializers.ValidationError('Bookings start on the hour or at half past.')
return value
def validate(self, attrs):
"""Object-level: called with every field's value, once each field has passed. A serializer never calls
Model.full_clean(), so this runs the model's own rules: clean() and the CHECK constraint."""
booking = Booking(**attrs) # unsaved, only to run its rules; on a PATCH, merge self.instance's values first
try:
booking.clean()
booking.validate_constraints()
except ModelValidationError as error:
errors = error.message_dict
if NON_FIELD_ERRORS in errors: # Django's key for errors of no field, '__all__', is not DRF's
errors[api_settings.NON_FIELD_ERRORS_KEY] = errors.pop(NON_FIELD_ERRORS)
raise serializers.ValidationError(errors) from None
return attrs
class BookingWithoutRules(serializers.ModelSerializer):
"""The same fields without validate(): what a ModelSerializer checks on its own."""
flat = serializers.SlugRelatedField(slug_field='code', queryset=Flat.objects.all())
class Meta:
model = Booking
fields = ['id', 'flat', 'starts_at', 'ends_at', 'guests']
class BookingRulesUnmapped(BookingWithoutRules):
"""The model's rules called as they are: DRF turns their errors into its own, keys and all."""
def validate(self, attrs):
booking = Booking(**attrs)
booking.clean()
booking.validate_constraints()
return attrs A serializer checks in a fixed order. Each field checks its own value first: its type, whether it is required, the
validators it took from the model field, then a validate_<field>() method if the class has one. Only when every
field has passed does validate(attrs) run, with all the values together. Step through the bookings of the run below
and watch where each one stops:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- Booking 1 arrives. Asha sends three hours from 18:00 for 8 guests. The serializer gets the JSON as data= and nothing is checked until is_valid() is called.
- Each field passes. flat must name a flat, starts_at and ends_at must parse as datetimes, guests must be from 1 to 10 (the model field's validators, copied into the serializer field), and validate_starts_at() wants the hour or half past.
- The rules of the whole booking. validate() runs only once every field has passed. It builds an unsaved Booking and calls its clean() and validate_constraints(), so the model's rules run here.
- Saved. is_valid() is True, so save() calls create() and the booking is saved as booking 1. A viewset answers 201 with it.
- Two field errors at once. Booking 2 asks for 12 guests from 18:15. guests fails its maximum and validate_starts_at() fails on 18:15; both errors are kept, each under its field, and validate() never runs.
- A rule of the whole booking. Booking 3 lasts 6 hours. Every field is fine on its own, so validate() runs, and the model's clean() refuses more than 4 hours under ends_at.
- The same, without validate(). BookingWithoutRules has the same fields and no validate(). Nothing calls Model.clean(), so the 6-hour booking is valid and saved as booking 3.
- Ending before it starts. Booking 4 ends at 17:00. With validate() the CHECK constraint's message comes back under non_field_errors. Without it, save() reaches the database and raises IntegrityError, which a viewset turns into a 500.
This program sends five bookings to two serializers, BookingSerializer and BookingWithoutRules, which has the
same fields and no validate(). It prints each verdict and the errors as the JSON a client gets:
"""Five bookings of the clubhouse sent to two serializers: BookingSerializer, which runs the model's rules in
validate(), and BookingWithoutRules, the same fields without validate(). Errors are printed as the JSON a client gets.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import json
import os
from datetime import datetime, timedelta, timezone
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.db import IntegrityError, transaction # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
from amenities.models import Booking # noqa: E402
from api.serializers import BookingRulesUnmapped, BookingSerializer, BookingWithoutRules # noqa: E402
from billing.models import Flat # noqa: E402
Flat.objects.create(code='B-1203')
six_pm = datetime(2026, 11, 14, 18, 0, tzinfo=timezone.utc)
def booking(start, hours, guests):
return {'flat': 'B-1203', 'starts_at': start.isoformat(), 'ends_at': (start + timedelta(hours=hours)).isoformat(), 'guests': guests}
def send(serializer_class, data):
serializer = serializer_class(data=data)
if not serializer.is_valid():
return f'invalid: {json.dumps(serializer.errors)}'
try:
with transaction.atomic():
saved = serializer.save()
except IntegrityError as error:
return f'valid, then save() raised IntegrityError: {error}'
return f'valid, saved as booking {saved.pk}'
cases = [
('1. 3 hours from 18:00 for 8 guests', booking(six_pm, 3, 8)),
('2. 12 guests from 18:15', booking(six_pm + timedelta(minutes=15), 2, 12)),
('3. 6 hours, against the society rule of at most 4', booking(six_pm, 6, 4)),
('4. ends at 17:00, an hour before it starts', booking(six_pm, -1, 4)),
('5. no guests field at all', {key: value for key, value in booking(six_pm, 2, 4).items() if key != 'guests'}),
]
for title, data in cases:
print(title)
print(f' BookingSerializer: {send(BookingSerializer, data)}')
print(f' BookingWithoutRules: {send(BookingWithoutRules, data)}')
print('Booking 1 as JSON:', json.dumps(BookingSerializer(Booking.objects.get(pk=1)).data))
print('Case 4 with the model rules called but their errors not mapped:', send(BookingRulesUnmapped, cases[3][1]))
long = sum(b.ends_at - b.starts_at > timedelta(hours=4) for b in Booking.objects.all())
print(f'Bookings saved: {Booking.objects.count()}, of which longer than 4 hours: {long}') Output
1. 3 hours from 18:00 for 8 guests
BookingSerializer: valid, saved as booking 1
BookingWithoutRules: valid, saved as booking 2
2. 12 guests from 18:15
BookingSerializer: invalid: {"starts_at": ["Bookings start on the hour or at half past."], "guests": ["Ensure this value is less than or equal to 10."]}
BookingWithoutRules: invalid: {"guests": ["Ensure this value is less than or equal to 10."]}
3. 6 hours, against the society rule of at most 4
BookingSerializer: invalid: {"ends_at": ["A booking can last at most 4 hours."]}
BookingWithoutRules: valid, saved as booking 3
4. ends at 17:00, an hour before it starts
BookingSerializer: invalid: {"non_field_errors": ["A booking must end after it starts."]}
BookingWithoutRules: valid, then save() raised IntegrityError: CHECK constraint failed: booking_ends_after_start
5. no guests field at all
BookingSerializer: invalid: {"guests": ["This field is required."]}
BookingWithoutRules: invalid: {"guests": ["This field is required."]}
Booking 1 as JSON: {"id": 1, "flat": "B-1203", "starts_at": "2026-11-14T18:00:00Z", "ends_at": "2026-11-14T21:00:00Z", "guests": 8}
Case 4 with the model rules called but their errors not mapped: invalid: {"__all__": ["A booking must end after it starts."]}
Bookings saved: 3, of which longer than 4 hours: 1
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 serializer_validation.py
Booking 2 shows the field stage at work: 12 guests broke the model’s MaxValueValidator(10), which the
ModelSerializer copied into its field, and 18:15 broke validate_starts_at(). Both errors came back at once, each
under its field’s name in a list, and validate() never ran. A viewset answers 400 with exactly that body, so an app
can put each message next to its input. Booking 5, without guests, got This field is required.
Checkpoint
Check: The order of a serializer's checks
A ModelSerializer skips the model’s own rules
Booking 3 lasts six hours, and the society allows four. BookingWithoutRules found nothing wrong and saved it, and so
the last line of the run counts one booking longer than four hours. The rule lives in Booking.clean(), and DRF’s
documentation is plain about why nobody called it: “With REST framework the validation is performed entirely on the
serializer class.” A ModelForm calls full_clean(), as the lesson on validation in depth showed; a serializer does
not. It never runs Model.clean(), and it never checks a CheckConstraint, which is why booking 4, which ends before
it starts, passed is_valid() and then hit the database with IntegrityError. Inside a viewset that error is a
500 Internal Server Error, not a 400.
BookingSerializer.validate() closes both gaps without copying the rules. It builds an unsaved Booking from the
values and calls its clean() and validate_constraints(), so each rule is still written once, in the model:
| Booking | BookingWithoutRules |
BookingSerializer |
|---|---|---|
| 12 guests from 18:15 | 400 for guests |
400 for starts_at and guests |
| 6 hours, the limit is 4 | saved | 400: A booking can last at most 4 hours. |
| ends before it starts | IntegrityError, a 500 in a view |
400: A booking must end after it starts. |
Trusting the model to check what the API saves
A rule in Model.clean() protects every ModelForm and nothing that a serializer saves, so an app can write data
the web site would refuse. Call the model’s rules from validate(), as BookingSerializer does, and on a PATCH
merge the values of self.instance first: attrs then holds only the fields the client sent.
One detail needs a line of code. Django files an error that belongs to no field under the key '__all__', while DRF
calls that key non_field_errors. The run’s next-to-last line shows the model’s rules called without the mapping:
the client got {"__all__": [...]}. validate() renames the key before it raises.
Key idea
A serializer checks only what it declares: its fields, the validators it copied from the model fields, its
validate_<field>() methods and validate(). Every other rule of the model must be called from validate().
Checkpoint
Check: The model rules a serializer skips
One viewset and a router make every endpoint
A viewset: In Django REST framework, one class that holds all the endpoints of a resource as actions, such as list, create, retrieve, update and destroy, which a router turns into URLs. is one class for all the endpoints of a model. ModelViewSet has six actions,
list, create, retrieve, update, partial_update and destroy, and needs only the serializer and the rows to
work on. ComplaintViewSet decides two things itself: get_queryset() holds only the complaints of the signed-in
resident’s flats, and perform_create() sets the reporter from the session:
api/views.py · nivas/api/views.py
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from rest_framework.filters import OrderingFilter
from billing.models import Bill
from complaints.models import Complaint
from .serializers import BillSerializer, ComplaintSerializer
class ComplaintViewSet(viewsets.ModelViewSet):
"""list, create, retrieve, update, partial_update and destroy, for the complaints of the resident's flats."""
serializer_class = ComplaintSerializer
def get_queryset(self):
# Every action looks objects up here, so another flat's complaint is a 404 for retrieve, update and destroy.
return Complaint.objects.filter(flat__members=self.request.user).select_related('flat', 'reporter').order_by('-pk')
def perform_create(self, serializer):
serializer.save(reporter=self.request.user) # set from the session, never from the request body
class BillViewSet(viewsets.ReadOnlyModelViewSet):
"""list and retrieve only: bills are made by the committee's billing run, not through the API."""
serializer_class = BillSerializer
filter_backends = [DjangoFilterBackend, OrderingFilter]
filterset_fields = ['paid', 'month'] # ?paid=false, ?month=2026-09-01
ordering_fields = ['month', 'amount_paise'] # the only fields ?ordering= may name
ordering = ['-month'] # newest first when ?ordering= is absent
def get_queryset(self):
return Bill.objects.visible_to(self.request.user).select_related('flat')
class BillViewSetN1(BillViewSet):
"""The same endpoint without select_related('flat'): each bill's flat code costs one more query."""
def get_queryset(self):
return Bill.objects.visible_to(self.request.user) api/urls.py · nivas/api/urls.py
from rest_framework.routers import DefaultRouter
from .views import BillViewSet, ComplaintViewSet
router = DefaultRouter()
router.register('complaints', ComplaintViewSet, basename='complaint') # basename: the viewset has no queryset attribute
router.register('bills', BillViewSet, basename='bill')
urlpatterns = router.urls A router: In Django REST framework, an object that turns each registered viewset into URL patterns, such as complaints/ for list and create and complaints/<pk>/ for the other actions, named after the viewset's basename. turns a registered viewset into URL patterns. For complaints it made two
names, complaint-list for complaints/ and complaint-detail for complaints/<pk>/, each with a second pattern for
a format suffix such as .json, as the first lines of the run below show, and it maps each HTTP method to an action:
| Request | Action | Answer on success |
|---|---|---|
GET /api/complaints/ |
list |
200, one page of complaints |
POST /api/complaints/ |
create |
201, with a Location header |
GET /api/complaints/1/ |
retrieve |
200, one complaint |
PUT or PATCH /api/complaints/1/ |
update, partial_update |
200, the changed complaint |
DELETE /api/complaints/1/ |
destroy |
204, no body |
The names come from the basename, which the router takes from a queryset attribute. ComplaintViewSet has
get_queryset() instead, so it is registered with basename='complaint'. Follow two requests through the viewset,
then read the whole run:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- The router picks create. Asha's app sends POST /api/complaints/ with flat, category, description and status: resolved. The router maps POST on complaints/ to the viewset's create action.
- Who is asking. SessionAuthentication finds Asha's session, and IsAuthenticated lets her through. Without a session the request would stop here with 403.
- The serializer checks the body. flat must be one of Asha's own flats, category one of the choices. status is read-only, so the value the app sent is dropped without a word.
- The view sets the reporter. perform_create() calls serializer.save(reporter=request.user). The reporter comes from the session, never from the body.
- 201 and its Location. The new complaint goes back as JSON with status open, and its url field becomes the Location header: http://testserver/api/complaints/1/.
- Ravi asks for complaint 1. Ravi lives in C-0904. His app asks for /api/complaints/1/; the router maps it to retrieve, which looks pk 1 up inside get_queryset().
- Only his flats' complaints. get_queryset() holds the complaints of Ravi's flats only, so the query for pk 1 finds no row.
- 404, as for no complaint at all. get_object() raises Http404 and DRF answers 404 with {"detail": "No Complaint matches the given query."}, the same answer as for a number that does not exist.
"""The complaints endpoints of the Nivas API, called by Asha of B-1203, Ravi of C-0904 and a visitor who has not
signed in. Prints the routes the router made, then each request and the status code, Location and JSON it got.
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import json
import logging
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.contrib.auth import get_user_model # noqa: E402
from django.test.utils import setup_databases, setup_test_environment # noqa: E402
from rest_framework.test import APIClient # noqa: E402
from api.urls import router # noqa: E402
from billing.models import Flat # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL) # the 4xx answers below are expected
print('Routes of the complaints viewset:')
for pattern in router.urls:
if pattern.name and pattern.name.startswith('complaint'):
print(f' {str(pattern.pattern):40} name={pattern.name}')
Resident = get_user_model()
asha = Resident.objects.create_user('98000 00001', 'Asha Rao')
ravi = Resident.objects.create_user('98000 00002', 'Ravi Iyer')
Flat.objects.create(code='B-1203').members.add(asha)
Flat.objects.create(code='C-0904').members.add(ravi)
def call(who, method, path, body=None):
client = APIClient()
if who:
client.force_login(who)
response = getattr(client, method)(path, body, format='json')
name = who.name.split()[0] if who else 'Visitor'
location = f' Location: {response["Location"]}' if response.has_header('Location') else ''
print(f'{name}: {method.upper()} {path} {json.dumps(body) if body else ""}'.rstrip())
print(f' {response.status_code} {response.reason_phrase}{location}')
if response.content:
print(f' {json.dumps(response.json())}')
print('Requests:')
call(None, 'get', '/api/complaints/')
call(asha, 'post', '/api/complaints/', {'flat': 'B-1203', 'category': 'lift', 'description': 'Lift B stops between floors 7 and 8', 'status': 'resolved'})
call(asha, 'post', '/api/complaints/', {'flat': 'C-0904', 'category': 'plumbing', 'description': 'Leak above the kitchen'})
call(asha, 'post', '/api/complaints/', {'flat': 'B-1203', 'category': 'roof'})
call(ravi, 'get', '/api/complaints/1/')
call(asha, 'patch', '/api/complaints/1/', {'description': 'Lift B stops between floors 7 and 8, twice today'})
call(asha, 'get', '/api/complaints/')
call(asha, 'delete', '/api/complaints/1/')
call(asha, 'get', '/api/complaints/1/') Output
Routes of the complaints viewset:
^complaints/$ name=complaint-list
^complaints\.(?P<format>[a-z0-9]+)/?$ name=complaint-list
^complaints/(?P<pk>[^/.]+)/$ name=complaint-detail
^complaints/(?P<pk>[^/.]+)\.(?P<format>[a-z0-9]+)/?$ name=complaint-detail
Requests:
Visitor: GET /api/complaints/
403 Forbidden
{"detail": "Authentication credentials were not provided."}
Asha: POST /api/complaints/ {"flat": "B-1203", "category": "lift", "description": "Lift B stops between floors 7 and 8", "status": "resolved"}
201 Created Location: http://testserver/api/complaints/1/
{"url": "http://testserver/api/complaints/1/", "id": 1, "flat": "B-1203", "category": "lift", "description": "Lift B stops between floors 7 and 8", "status": "open", "reporter": "Asha Rao"}
Asha: POST /api/complaints/ {"flat": "C-0904", "category": "plumbing", "description": "Leak above the kitchen"}
400 Bad Request
{"flat": ["Object with code=C-0904 does not exist."]}
Asha: POST /api/complaints/ {"flat": "B-1203", "category": "roof"}
400 Bad Request
{"category": ["\"roof\" is not a valid choice."], "description": ["This field is required."]}
Ravi: GET /api/complaints/1/
404 Not Found
{"detail": "No Complaint matches the given query."}
Asha: PATCH /api/complaints/1/ {"description": "Lift B stops between floors 7 and 8, twice today"}
200 OK
{"url": "http://testserver/api/complaints/1/", "id": 1, "flat": "B-1203", "category": "lift", "description": "Lift B stops between floors 7 and 8, twice today", "status": "open", "reporter": "Asha Rao"}
Asha: GET /api/complaints/
200 OK
{"count": 1, "next": null, "previous": null, "results": [{"url": "http://testserver/api/complaints/1/", "id": 1, "flat": "B-1203", "category": "lift", "description": "Lift B stops between floors 7 and 8, twice today", "status": "open", "reporter": "Asha Rao"}]}
Asha: DELETE /api/complaints/1/
204 No Content
Asha: GET /api/complaints/1/
404 Not Found
{"detail": "No Complaint matches the given query."}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 complaints_api.py
Asha’s first POST sent "status": "resolved", and the complaint was created open: status is in read_only_fields,
so the serializer drops it. That is how a serializer stops
mass assignment: A weakness in which a request sets fields of a row it was never meant to set, because the code copies every submitted key, or every field of a model, into the row. The cure is a list of the fields a request may set. More in “ModelForms, formsets and inline formsets”, which OWASP’s API Security Top 10 2023 files as API3, broken
object property level authorization. Her second POST named C-0904 and got Object with code=C-0904 does not exist.,
because ResidentFlatField looks codes up among her own flats only. Ravi’s request for complaint 1 got 404: like the
generic views of the previous module, every action finds its object inside get_queryset().
A queryset attribute and no get_queryset()
queryset = Complaint.objects.all() on the viewset gives every signed-in resident every flat’s complaints, through
the list and through the detail URL alike. Keep the class attribute only for rows that everyone may see, and scope
the rest in get_queryset(), where self.request.user is known.
Checkpoint
Check: Viewsets, routers and the scoped queryset
Every answer carries its status code
A client decides what to do from the status code: The three-digit number at the start of an HTTP response that says what happened, such as 200 OK, 201 Created, 302 Found for a redirect, 404 Not Found or 405 Method Not Allowed. More in “Views, requests and responses” before it reads a word of the body, so each answer must say what happened. These are the codes the runs of this lesson recorded:
| Code | When the Nivas API answered it |
|---|---|
| 200 OK | a list, and a PATCH that changed a complaint |
| 201 Created | a POST that created a complaint; Location names it |
| 204 No Content | a DELETE; the body is empty |
| 400 Bad Request | a body that failed validation, with the errors of each field |
| 403 Forbidden | the visitor’s request, which carried no session |
| 404 Not Found | another flat’s complaint, a deleted one, or ?page=4 of three pages |
| 405 Method Not Allowed | a POST to /api/bills/, whose viewset is read-only |
The visitor’s 403 surprises people who expect 401 for “not signed in”. DRF’s rule is that the first authentication
class of the view decides: a 401 must carry a WWW-Authenticate header that tells the client how to sign in, and
SessionAuthentication, the only class here, has no such header to send, so DRF answers 403. A token or JWT class in
first place gives 401 instead, which the next lesson shows. Write tests for the code your authentication classes
produce.
Error bodies come in two shapes: {"field": ["message"]} for validation and {"detail": "message"} for everything
else. They are DRF’s own format. A public API may prefer the standard one of RFC 9457, problem details, which the
api-design track covers; a custom exception handler can produce it.
Checkpoint
Check: The status code of each answer
Pages, filters and ordering come from the query string
A list endpoint that returns every row is fast with 5 bills and useless with 50,000. The Nivas settings set
PageNumberPagination with PAGE_SIZE 10 for the whole API, which DRF leaves off unless both settings are given.
BillViewSet is a ReadOnlyModelViewSet, list and retrieve only, with two filter backends:
DjangoFilterBackend from django-filter 26.2 turns ?paid=false and ?month=2026-09-01 into filters on the
filterset_fields, and OrderingFilter sorts by ?ordering=, but only by the fields in ordering_fields. Follow
the requests through the order DRF applies them, then read the run, with 24 monthly bills for each of two flats:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The scope comes first. Asha's request reaches list(), which starts from get_queryset(): the bills of her own flat, 24 of the 48 in the database.
- Filters from the query string. ?paid=false makes DjangoFilterBackend add paid = false to the query: 3 bills are left. Only paid and month are filter fields; any other key is ignored.
- The order, from an allowlist. ?ordering=-amount_paise sorts by amount, highest first. ?ordering=flat__members__phone names a field outside ordering_fields, so it is dropped and the default, newest month first, stays.
- One page of ten. With no filter, the paginator counts 24 bills, then asks for 10 rows: pages of 10, 10 and 4. The page holds the links to the next and the previous page.
- A page that does not exist. ?page=4 asks for rows 31 to 40 of 24. The paginator raises NotFound and the answer is 404 with {"detail": "Invalid page."}.
- Each bill becomes JSON. The serializer turns each bill of the page into id, flat, month, amount_paise and paid. The flat's code needs the flat row: with select_related('flat') it came in the same query, without it one more query per bill, 10 a page.
"""The bills endpoint of the Nivas API: pages, filters and ordering, as Asha of B-1203 calls it, then the queries one
page costs with and without select_related('flat').
Run it next to manage.py. It makes a test database in memory, as manage.py test does."""
import datetime
import json
import logging
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
import django # noqa: E402
django.setup()
from django.contrib.auth import get_user_model # noqa: E402
from django.db import connection # noqa: E402
from django.test.utils import CaptureQueriesContext, setup_databases, setup_test_environment # noqa: E402
from rest_framework.test import APIClient # noqa: E402
from billing.models import Bill, Flat # noqa: E402
setup_test_environment()
setup_databases(verbosity=0, interactive=False, aliases={'default'})
logging.getLogger('django.request').setLevel(logging.CRITICAL) # the 404 and 405 answers below are expected
Resident = get_user_model()
asha = Resident.objects.create_user('98000 00001', 'Asha Rao')
ravi = Resident.objects.create_user('98000 00002', 'Ravi Iyer')
b1203, c0904 = Flat.objects.create(code='B-1203'), Flat.objects.create(code='C-0904')
b1203.members.add(asha)
c0904.members.add(ravi)
# 24 monthly bills for each flat, October 2024 to September 2026; the last three are unpaid. The water charge makes
# each month's amount different.
for flat, base in [(b1203, 245_000), (c0904, 198_000)]:
for i in range(24):
month = datetime.date(2024 + (9 + i) // 12, (9 + i) % 12 + 1, 1)
Bill.objects.create(flat=flat, month=month, amount_paise=base + 150 * (i * 7 % 24), paid=i < 21)
client = APIClient()
client.force_login(asha)
def get(path, show='summary'):
response = client.get(path)
print(f'GET {path}')
body = response.json()
if response.status_code != 200:
print(f' {response.status_code} {json.dumps(body)}')
elif show == 'summary':
months = [bill['month'][:7] for bill in body['results']]
print(f' 200 count={body["count"]} next={body["next"]} previous={body["previous"]}')
print(f' {len(months)} results: {months[0]} … {months[-1]}' if len(months) > 2 else f' results: {json.dumps(body["results"])}')
else:
print(f' 200 count={body["count"]}, first result: {json.dumps(body["results"][0])}')
get('/api/bills/')
get('/api/bills/?page=3')
get('/api/bills/?page=4')
get('/api/bills/?paid=false')
get('/api/bills/?paid=false&month=2026-09-01')
get('/api/bills/?ordering=-amount_paise', show='first')
get('/api/bills/?ordering=flat__members__phone', show='first')
response = client.post('/api/bills/', {'amount_paise': 1}, format='json')
print(f'POST /api/bills/\n {response.status_code} {json.dumps(response.json())}')
for path in ['/api/bills/', '/lab/bills-without-join/']:
with CaptureQueriesContext(connection) as queries:
client.get(path)
sql = [query['sql'] for query in queries]
bills = sum('FROM "billing_bill"' in s for s in sql)
flats = sum(s.startswith('SELECT') and 'FROM "billing_flat"' in s for s in sql)
print(f'One page of {path}: {len(sql)} queries; {bills} read bills, {flats} read flats, {len(sql) - bills - flats} the session and permissions') Output
GET /api/bills/
200 count=24 next=http://testserver/api/bills/?page=2 previous=None
10 results: 2026-09 … 2025-12
GET /api/bills/?page=3
200 count=24 next=None previous=http://testserver/api/bills/?page=2
4 results: 2025-01 … 2024-10
GET /api/bills/?page=4
404 {"detail": "Invalid page."}
GET /api/bills/?paid=false
200 count=3 next=None previous=None
3 results: 2026-09 … 2026-07
GET /api/bills/?paid=false&month=2026-09-01
200 count=1 next=None previous=None
results: [{"id": 24, "flat": "B-1203", "month": "2026-09-01", "amount_paise": 247550, "paid": false}]
GET /api/bills/?ordering=-amount_paise
200 count=24, first result: {"id": 18, "flat": "B-1203", "month": "2026-03-01", "amount_paise": 248450, "paid": true}
GET /api/bills/?ordering=flat__members__phone
200 count=24, first result: {"id": 24, "flat": "B-1203", "month": "2026-09-01", "amount_paise": 247550, "paid": false}
POST /api/bills/
405 {"detail": "Method \"POST\" not allowed."}
One page of /api/bills/: 6 queries; 2 read bills, 0 read flats, 4 the session and permissions
One page of /lab/bills-without-join/: 16 queries; 2 read bills, 10 read flats, 4 the session and permissions
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bills_api.py
Asha’s 24 bills came as pages of 10, 10 and 4, each with count and the links to the next and previous pages, and
?page=4 answered 404 with Invalid page. ?paid=false left the three unpaid months. ?ordering=-amount_paise put
the highest bill first, and ?ordering=flat__members__phone was dropped without an error, because that field is not
in the allowlist, so the default order, newest month first, stayed.
Ordering without ordering_fields
Without ordering_fields, OrderingFilter lets a client sort by any readable field of the serializer, and with
ordering_fields = '__all__' by any field of the model. DRF’s documentation warns that this can leak data, such as
an order by a password hash. Name the fields a client may sort by.
The last two lines are the query count of one page. With select_related('flat') the page read bills twice, a
COUNT(*) and one SELECT that joined the flats; without it, each of the 10 bills cost one more query for its flat’s
code, the N+1 queries: One query that reads N rows, then one more query for each of them to read something the first one left out, such as each notice's poster. The number of queries grows with the rows a page reads. More in “N+1 queries, prefetching and 6.1 fetch modes” pattern of the ORM module, now hidden inside a serializer field.
Key idea
The query string only ever narrows the scoped queryset: filters and ordering name fields from your allowlists, the
paginator cuts a page, and a select_related() keeps each page to a fixed number of queries.
Checkpoint
Check: Pages, filters and ordering
Practise: a complaint endpoint to its contract
The Nivas mobile app is built against a written contract for POST /api/complaints/. Write it as one plain Python
function, with the status code, the Location header, the read-only fields and the error messages of the run, and
with sample tests that run in your browser:
Exercise · Medium · Python
Answer a complaint POST the way the API contract says
The Nivas mobile app is built against a written contract for POST /api/complaints/, the contract the lesson's ComplaintSerializer and ComplaintViewSet meet. Write it here as one plain Python function, so that the rules are yours and not the framework's: create_complaint(user, data, next_id) returns (status, headers, body).
- Read only
flat,categoryanddescriptionfromdata. Any other key,statusandreporterincluded, is ignored: a new complaint is always'open', and its reporter is the user's name. - Check every field and report every problem at once, as a dictionary from the field to a list of messages, with the messages the lesson's run printed:
- a field that is missing from
data:This field is required. - a category that is not plumbing, electrical or lift:
"roof" is not a valid choice.(the value in double quotes) - a flat that is not one of the user's flats:
Object with code=C-0904 does not exist. - an empty description:
This field may not be blank. - a description of more than 200 characters:
Ensure this field has no more than 200 characters.
- a field that is missing from
- On a problem, return
(400, {}, errors). - Otherwise return
201, the headerLocationwith/api/complaints/<next_id>/, and the new complaint:url(the same address),id,flat,category,description,statusandreporter.
FLATS_OF says which flats each user lives in.
Starter code · contract.py
FLATS_OF = {'Asha Rao': {'B-1203'}, 'Ravi Iyer': {'C-0904'}, 'Kabir Shah': {'B-1203', 'C-0904'}}
CATEGORIES = ['plumbing', 'electrical', 'lift']
def create_complaint(user, data, next_id):
"""What POST /api/complaints/ answers: (status, headers, body)."""
complaint = dict(data) # trusts every key the app sent, status included
complaint['id'] = next_id
return 201, {}, complaint The sample tests · test_contract.py
from contract import create_complaint
GOOD = {'flat': 'B-1203', 'category': 'lift', 'description': 'Lift B stops between floors 7 and 8'}
def test_a_good_complaint_is_created_with_its_location():
status, headers, body = create_complaint('Asha Rao', GOOD, 1)
assert status == 201
assert headers == {'Location': '/api/complaints/1/'}
assert body == {
'url': '/api/complaints/1/',
'id': 1,
'flat': 'B-1203',
'category': 'lift',
'description': 'Lift B stops between floors 7 and 8',
'status': 'open',
'reporter': 'Asha Rao',
}
def test_status_and_reporter_from_the_app_are_ignored():
status, _, body = create_complaint('Asha Rao', {**GOOD, 'status': 'resolved', 'reporter': 'Meera Pillai'}, 2)
assert status == 201
assert body['status'] == 'open'
assert body['reporter'] == 'Asha Rao'
def test_another_residents_flat_is_refused():
status, headers, body = create_complaint('Asha Rao', {**GOOD, 'flat': 'C-0904'}, 3)
assert (status, headers) == (400, {})
assert body == {'flat': ['Object with code=C-0904 does not exist.']}
def test_every_problem_is_reported_at_once():
status, _, body = create_complaint('Asha Rao', {'flat': 'B-1203', 'category': 'roof'}, 4)
assert status == 400
assert body == {'category': ['"roof" is not a valid choice.'], 'description': ['This field is required.']}
def test_an_empty_request_names_all_three_fields():
status, _, body = create_complaint('Ravi Iyer', {}, 5)
assert status == 400
assert sorted(body) == ['category', 'description', 'flat']
assert all(messages == ['This field is required.'] for messages in body.values())
def test_an_empty_description_is_blank_not_missing():
status, _, body = create_complaint('Ravi Iyer', {'flat': 'C-0904', 'category': 'plumbing', 'description': ''}, 6)
assert status == 400
assert body == {'description': ['This field may not be blank.']}
def test_a_long_description_is_refused():
status, _, body = create_complaint('Ravi Iyer', {'flat': 'C-0904', 'category': 'plumbing', 'description': 'x' * 201}, 6)
assert status == 400
assert body == {'description': ['Ensure this field has no more than 200 characters.']}
def test_200_characters_are_enough():
status, _, _ = create_complaint('Ravi Iyer', {'flat': 'C-0904', 'category': 'plumbing', 'description': 'x' * 200}, 7)
assert status == 201
def test_a_resident_of_two_flats_may_use_either():
for flat in ['B-1203', 'C-0904']:
status, _, body = create_complaint('Kabir Shah', {**GOOD, 'flat': flat}, 8)
assert status == 201
assert body['flat'] == flat A hint
Start with an empty errors dictionary and check each of the three fields in turn, adding to errors.setdefault(field, []) instead of returning at the first problem. Only after all three checks, return 400 if errors holds anything. Build the body from the three checked values and from values you choose, never from data['status'].
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then work through the five ideas of the lesson, the one you are least sure of first, with a reason for every answer:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher): What does a serializer do in Django REST framework, and how is it different from a form?
It converts both ways: model objects into Python data that becomes JSON, and JSON from a request into checked values
that save() passes to create() or update(). Like a form, it validates field by field, then with validate(),
and reports errors per field. Two things differ. A ModelSerializer never calls the model’s full_clean(), so
Model.clean() and check constraints run only if validate() calls them, while a ModelForm runs them. And a
serializer renders no HTML: it describes data, and the viewset chooses the status code.
Two longer questions of the module’s bank build on the bookings, complaints and bills of these runs:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
The complaints API of a housing-society app must let the committee resolve complaints, while residents may only report them and correct their own text. Today status is read-only for everyone. How do you change the API, and what do you test?
-
Mid to senior
A page of the committee's bills endpoint, 25 bills a page, runs 31 queries. The serializer shows each bill's flat code. Where do the queries come from, how do you fix them, and how do you keep them fixed?
Key takeaways
- A serializer checks each field on its own, its validators and
validate_<field>()included, and runsvalidate()only when every field has passed; every error comes back under its field’s name. - A
ModelSerializernever callsfull_clean(), soModel.clean()and check constraints run only whenvalidate()calls them; map Django’s'__all__'key tonon_field_errorswhen it does. - A
ModelViewSetgiveslist,create,retrieve,update,partial_updateanddestroy, and a router turns it into-listand-detailURLs; scopeget_queryset()and set the owner inperform_create(). createanswers 201 withLocationfrom aurlfield,destroy204, validation errors 400, and a request without a session 403 whenSessionAuthenticationcomes first.- Pagination,
DjangoFilterBackendandOrderingFilternarrow the scoped queryset from the query string; allowlist the ordering fields and keep each page to a fixed number of queries withselect_related().
Terms in this lesson
- router
- In Django REST framework, an object that turns each registered viewset into URL patterns, such as complaints/ for list and create and complaints/<pk>/ for the other actions, named after the viewset's basename.
- serializer
- In Django REST framework, a class that turns model objects into data that becomes JSON, and checks the data of a request field by field and then as a whole, before save() creates or updates an object.
- viewset
- In Django REST framework, one class that holds all the endpoints of a resource as actions, such as list, create, retrieve, update and destroy, which a router turns into URLs.
References
- Django REST framework (Django REST framework (Encode))
- API guide: Serializers (Django REST framework (Encode))
- API guide: Validators (Django REST framework (Encode))
- API guide: Generic views (Django REST framework (Encode))
- API guide: ViewSets (Django REST framework (Encode))
- API guide: Routers (Django REST framework (Encode))
- API guide: Authentication (Django REST framework (Encode))
- API guide: Pagination (Django REST framework (Encode))
- API guide: Filtering (Django REST framework (Encode))
- django-filter documentation (django-filter project)
- OWASP API Security Top 10 2023 (OWASP Foundation)
- RFC 9457: Problem Details for HTTP APIs (IETF)
- Django Developers Survey 2026 results (Django Software Foundation and JetBrains)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress