Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with Python 3.14.8, Django 6.1.2 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

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:

The serializers of the Nivas API Python · nivas/api/serializers.py
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:

Interactive · stage by stage How a serializer checks one booking Step through the bookings of the recorded run, from the fields of the JSON to save().
How a serializer checks one booking, step 8 of 8. Showing JSON from the request (18:00 to 00:00, 4 guests), Every field on its own, validate(attrs) (validate(attrs)), save() (IntegrityError: CHECK failed), Valid or invalid (ends_at error). In focus: save().any erroran errorJSON from the request18:00 to 00:00, 4 guestsEvery field on its owntypes, validators, methodsvalidate(attrs)validate(attrs)save()IntegrityError: CHECK failedValid or invalidends_at error

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. Saved. is_valid() is True, so save() calls create() and the booking is saved as booking 1. A viewset answers 201 with it.
  5. 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.
  6. 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.
  7. 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.
  8. 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, two serializers Python · nivas/serializer_validation.py
"""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

  1. Question 1 of 2 Booking 2 asked for 12 guests from 18:15. What did BookingSerializer report?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Two errors, one under starts_at and one under guests

    The field stage checks every field and keeps every error; the object stage, validate(), waits until all of them have passed.

  2. Question 2 of 2 When does a serializer call its validate(attrs) method?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: After every field has passed its own checks

    Each field first, with its validators and validate_<field>(); then validate() once with attrs, the whole object.

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

  1. Question 1 of 2 Booking 3 lasts 6 hours, and Booking.clean() allows 4. What did BookingWithoutRules do with it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It saved the booking, because a serializer never calls the model's clean()

    DRF validates entirely in the serializer. Model.clean() runs only if your validate() calls it.

  2. Question 2 of 2 Booking 4 ends before it starts. Sent through BookingWithoutRules inside a viewset, what does the client get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 500 Internal Server Error, because save() raised IntegrityError

    A constraint the serializer never checked is found by the database, and an unhandled database error is a 500.

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:

The viewsets and the router

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:

Interactive · message by message A complaint created, then looked up by another flat Step through Asha's POST and Ravi's GET as the recorded run made them, one call at a time.
A complaint created, then looked up by another flat, step 8 of 8. Messages so far: Nivas app to ComplaintViewSet: POST /api/complaints/; ComplaintViewSet to Database: INSERT, reporter Asha; ComplaintViewSet to Nivas app: 201 Created; Nivas app to ComplaintViewSet: GET /api/complaints/1/; ComplaintViewSet to Database: pk 1 AND member Ravi; ComplaintViewSet to Nivas app: 404 Not Found.Nivas appRavi of C-0904ComplaintViewSetretrieve: RaviDatabasePOST /api/complaints/INSERT, reporter Asha201 CreatedGET /api/complaints/1/pk 1 AND member Ravi404 Not Found

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. 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.
  2. Who is asking. SessionAuthentication finds Asha's session, and IsAuthenticated lets her through. Without a session the request would stop here with 403.
  3. 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.
  4. The view sets the reporter. perform_create() calls serializer.save(reporter=request.user). The reporter comes from the session, never from the body.
  5. 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/.
  6. 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().
  7. Only his flats' complaints. get_queryset() holds the complaints of Ravi's flats only, so the query for pk 1 finds no row.
  8. 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 complaint endpoints, called by Asha, Ravi and a visitor Python · nivas/complaints_api.py
"""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

  1. Question 1 of 2 Why is ComplaintViewSet registered with basename="complaint"?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It has get_queryset() and no queryset attribute, and the router takes the basename from that attribute

    The basename names the routes, complaint-list and complaint-detail, which reverse() and HyperlinkedIdentityField use.

  2. Question 2 of 2 Asha's POST sent "status": "resolved". What status did the new complaint get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: open, because status is read-only and the serializer drops the value

    A field in read_only_fields appears in responses and is ignored in requests, so a client cannot set it.

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.

HTTP Status Codes Reference Look up 201, 204, 403 and 405, and what a client is expected to do with each.

Checkpoint

Check: The status code of each answer

  1. Question 1 of 2 A visitor without a session called GET /api/complaints/. Why did DRF answer 403 and not 401?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first authentication class, SessionAuthentication, has no WWW-Authenticate header, and a 401 must carry one

    401 needs a WWW-Authenticate challenge. Session cookies have none, so a session-first API answers 403.

  2. Question 2 of 2 What did DELETE /api/complaints/1/ answer?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 204 No Content, with an empty body

    204 says it worked and there is nothing to show; the next GET of the same URL is a 404.

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:

Interactive · stage by stage What a list request does with its query string Step through Asha's requests for her bills, from the scoped queryset to the JSON page.
What a list request does with its query string, step 6 of 6. Showing get_queryset() (Asha's bills: 24 of 48), DjangoFilterBackend (no filter: 24 bills), OrderingFilter (-amount_paise, or -month), PageNumberPagination (COUNT 24, rows 1 to 10), Serializer to JSON (10 bills, next: page 2). In focus: Serializer to JSON.page 4: 404get_queryset()Asha's bills: 24 of 48DjangoFilterBackendno filter: 24 billsOrderingFilter-amount_paise, or -monthPageNumberPaginationCOUNT 24, rows 1 to 10Serializer to JSON10 bills, next: page 2

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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."}.
  6. 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.

Pages, filters and ordering of Asha's bills Python · nivas/bills_api.py
"""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

  1. Question 1 of 2 Asha has 24 bills and PAGE_SIZE is 10. How many bills are on page 3?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 4 bills

    Pages 1 and 2 hold 10 each, so page 3 holds the last 24 − 20 = 4, and page 4 is a 404 with "Invalid page.".

  2. Question 2 of 2 What did ?ordering=flat__members__phone do, with ordering_fields = ['month', 'amount_paise']?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing; the field is not in the allowlist, so the default order, newest month first, stayed

    A client may only sort by the fields you name; anything else is dropped silently, so test the order you expect.

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, category and description from data. Any other key, status and reporter included, 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.
  • On a problem, return (400, {}, errors).
  • Otherwise return 201, the header Location with /api/complaints/<next_id>/, and the new complaint: url (the same address), id, flat, category, description, status and reporter.

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'].

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.

  1. Question 1 of 13 How does a serializer report the errors of an invalid booking?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: A dictionary from each field's name to a list of messages

    Field errors are keyed by field; errors of the whole object go under non_field_errors.

  2. Question 2 of 13 A new rule says a booking for more than 6 guests must end by 22:00. Where do you check it?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: In validate(), because it needs guests and ends_at together

    One field: validate_<field>() or a validator. Several fields: validate(), or the model's clean() called from it.

  3. Question 3 of 13 Which of these runs Booking.clean() without any extra code?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: A ModelForm for Booking

    Only full_clean() calls clean(); ModelForm calls full_clean(); a serializer and save() do not.

  4. Question 4 of 13 validate() calls the model's rules without mapping their errors. Under which key does booking 4's error reach the client?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: __all__, Django's key for errors of no field

    Rename '__all__' to api_settings.NON_FIELD_ERRORS_KEY before raising, so the API keeps one error format.

  5. Question 5 of 13 A PATCH sends only {"ends_at": "…"} for an existing booking. Why must validate() merge self.instance's values before it calls Booking(**attrs).clean()?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: attrs holds only the fields the client sent, so the unsaved booking would have no starts_at to compare

    Rules of several fields need every value: the sent ones from attrs, the rest from the instance.

  6. Question 6 of 13 Which actions does a ModelViewSet provide? Choose every one.

    Choose every answer that is right.

    Show the answer: Question 6 of 13

    Answer:

    • partial_update
    • create
    • list
    • destroy

    list, create, retrieve, update, partial_update and destroy; ReadOnlyModelViewSet keeps list and retrieve only.

  7. Question 7 of 13 Where does ComplaintViewSet set the reporter of a new complaint, and why there?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: In perform_create(), with serializer.save(reporter=self.request.user), so it comes from the session

    Values the client must not choose stay out of the writable fields, and the view sets them from data it trusts.

  8. Question 8 of 13 A teammate adds a stats action, GET /api/complaints/stats/, that counts Complaint.objects.all() by category. What is wrong?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: It counts every flat's complaints; an action must start from self.get_queryset() like the others

    Every action of a viewset must start from the scoped queryset, or it opens the hole the scope closed.

  9. Question 9 of 13 Where did the Location header of the 201 response come from?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: The serializer's url field; CreateModelMixin copies it into Location

    Give the serializer a HyperlinkedIdentityField named url, and a create answers 201 with Location.

  10. Question 10 of 13 The mobile app team writes a test that expects 401 for a request without credentials, and it fails with 403. What do you change?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Either expect 403, the code SessionAuthentication-first produces, or put the token class first if the app uses tokens

    Decide which client signs in how, order the authentication classes by it, and test for the code that order gives.

  11. Question 11 of 13 GET /api/bills/?paid=false answered count 3. Which bills did the filter work on?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Asha's 24 bills, because filters narrow get_queryset(), which is scoped first

    get_queryset(), then the filter backends, then the paginator, then the serializer: each narrows what came before.

  12. Question 12 of 13 Without select_related('flat'), one page of 10 bills read the flats table how many times?

    Type a number.

    Show the answer: Question 12 of 13

    Answer: 10 queries

    The serializer reads each bill's flat code, and without the join each read is a query of its own: 10 for a page of 10, on top of the COUNT and the SELECT of bills.

  13. Question 13 of 13 Without ordering_fields on the view, which fields may ?ordering= name?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Any readable field of the serializer

    Name ordering_fields explicitly, so a client cannot sort by data the API was never meant to reveal.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A serializer checks each field on its own, its validators and validate_<field>() included, and runs validate() only when every field has passed; every error comes back under its field’s name.
  • A ModelSerializer never calls full_clean(), so Model.clean() and check constraints run only when validate() calls them; map Django’s '__all__' key to non_field_errors when it does.
  • A ModelViewSet gives list, create, retrieve, update, partial_update and destroy, and a router turns it into -list and -detail URLs; scope get_queryset() and set the owner in perform_create().
  • create answers 201 with Location from a url field, destroy 204, validation errors 400, and a request without a session 403 when SessionAuthentication comes first.
  • Pagination, DjangoFilterBackend and OrderingFilter narrow the scoped queryset from the query string; allowlist the ordering fields and keep each page to a fixed number of queries with select_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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.