RizTech Academy logo
RizTech Academy
Building an API with DRFLesson 2 of 530 min

ViewSets, routers and pagination

Writing a separate view for list, retrieve, create, update and delete of the same resource is repetitive — five views that differ only in which action they perform. DRF's ViewSets bundle all of them into one class, and routers generate the URLs automatically, so a full CRUD API for a resource becomes a few lines. Add pagination and your API scales to large datasets without sending everything at once. This lesson covers all three.

ModelViewSet: full CRUD in one class

A ModelViewSet provides list, retrieve, create, update, partial-update and destroy — the entire REST lifecycle for a model — from a queryset and a serializer:

# patients/api.py
from rest_framework import viewsets
from .models import Patient
from .serializers import PatientSerializer


class PatientViewSet(viewsets.ModelViewSet):
    queryset = Patient.objects.all().order_by("name")
    serializer_class = PatientSerializer

That is a complete CRUD API for patients. Verified: it serves GET /api/patients/ (list, 200), GET /api/patients/{id}/ (retrieve), POST /api/patients/ (create, 201), and the update/delete actions — all from those two lines. The ModelViewSet maps HTTP methods to actions for you: GET on the collection lists, GET on an item retrieves, POST creates, PUT/PATCH update, DELETE destroys. You wrote no per-action code.

Always set an explicit order_by on the queryset — an unordered queryset can return rows in different orders across requests, which breaks pagination (below).

Routers: URLs generated for you

A ViewSet has no URLs by itself — a router generates the standard REST URLs from it:

# patients/api_urls.py
from rest_framework.routers import DefaultRouter
from .api import PatientViewSet

router = DefaultRouter()
router.register(r"patients", PatientViewSet, basename="api-patient")
urlpatterns = router.urls
# config/urls.py
path("api/", include("patients.api_urls")),

router.register("patients", PatientViewSet) produces the whole set of URLs — /api/patients/ for the collection and /api/patients/{id}/ for an item — each wired to the right ViewSet action. Verified: the registered routes serve list, retrieve, create and the rest under /api/. The router also generates a handy API root view listing your registered endpoints. This is the pay-off: a ViewSet plus a router is a complete, conventionally-routed CRUD API with no hand-written URL patterns or per-action views.

When a ViewSet is too much: choose the right granularity

ModelViewSet gives you all actions. Sometimes you want fewer:

  • ReadOnlyModelViewSet — only list and retrieve (no create/update/delete). Right for data a client may read but not change (a public list of test types, say).
  • A ViewSet with specific mixins — compose exactly the actions you want.
  • Custom actions — add an extra endpoint with the @action decorator:
from rest_framework.decorators import action
from rest_framework.response import Response

class PatientViewSet(viewsets.ModelViewSet):
    # ...
    @action(detail=True, methods=["get"])
    def appointments(self, request, pk=None):
        patient = self.get_object()
        data = AppointmentSerializer(patient.appointments.all(), many=True).data
        return Response(data)

@action(detail=True) adds /api/patients/{id}/appointments/ — an extra endpoint beyond CRUD, routed automatically. Use detail=False for a collection-level action. This is how you extend a ViewSet with the non-CRUD endpoints a real API needs without abandoning the ViewSet's convenience.

Pagination: never return everything

A list endpoint that returns every row is a problem waiting for scale — ten thousand patients in one response is slow, memory-hungry, and unusable on a mobile client. DRF paginates automatically once configured:

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

Now every list endpoint returns one page and the metadata to fetch the rest:

{
  "count": 2450,
  "next": "http://.../api/patients/?page=2",
  "previous": null,
  "results": [ ... 20 patients ... ]
}

The client reads results for this page and follows next for more. PageNumberPagination is the simplest (?page=2); LimitOffsetPagination (?limit=20&offset=40) and cursor pagination are alternatives for different needs. The key discipline: a list endpoint should be paginated from the start. Set a global PAGE_SIZE and every list is bounded — retrofitting pagination after clients depend on unpaginated responses is a breaking change, so do it now.

Check your work

What ModelViewSet provides. Full CRUD — list, retrieve, create, update, partial-update, destroy — from a queryset and serializer_class, mapping HTTP methods to actions. Verified: list (200), retrieve, create (201) with no per-action code.

What a router does. Generates the standard REST URLs from a registered ViewSet (/api/patients/, /api/patients/{id}/) plus an API root — no hand-written URL patterns.

Choosing granularity. ReadOnlyModelViewSet for read-only resources; mixins for specific actions; @action to add non-CRUD endpoints (verified pattern: /patients/{id}/appointments/).

Why order the queryset. An unordered queryset returns rows in unstable order, breaking pagination — always set order_by.

Pagination. Configure DEFAULT_PAGINATION_CLASS + PAGE_SIZE; list responses become {count, next, previous, results}. Paginate from the start — retrofitting is a breaking change.

Practice

  1. Replace an APIView list with a ModelViewSet; register it with a DefaultRouter and confirm list, retrieve and create all work under /api/.
  2. Visit the router's API root and the browsable endpoints; create and edit a patient through the browsable API.
  3. Switch to ReadOnlyModelViewSet and confirm POST is now rejected (405) — the resource is read-only.
  4. Add an @action(detail=True) endpoint returning a patient's appointments; confirm the generated URL works.
  5. Configure PAGE_SIZE = 2 and confirm the list response has count, next, previous, results; follow next to page 2.
  6. Remove the order_by from the queryset and reason about why pagination could then return inconsistent pages.

Official documentation

Next: filtering, searching and ordering an API.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship