Make Django REST Framework Pagination an Explicit Client Contract
Unbounded list responses grow expensive as tables grow. Django REST Framework pagination wraps results in a bounded envelope. Configure PageNumberPagination globally, cap page sizes, and verify the response shape before clients depend on it.
30 Mar 2026, 01:50 UTC

The Problem: Unbounded List Responses
A ModelViewSet with queryset = Item.objects.all() returns every matching row until something breaks. As the table grows, clients download larger payloads, the database runs bigger scans, and response times become unpredictable. Django REST Framework (DRF) pagination turns that open-ended endpoint into a bounded contract: the server returns a fixed-size page plus links to the next and previous pages.
Useful takeaway: decide pagination globally, document it, and override per view only when there is a deliberate reason.
Set the Default Pagination Class
Pagination is not automatic. PAGE_SIZE alone does nothing unless a default pagination class is also configured. In settings.py:
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}
PageNumberPagination is the default paginator style most teams start with. It numbers pages starting at 1 and wraps results in a consistent envelope. Apply it to a viewset:
from rest_framework import viewsets
from .models import Item
from .serializers import ItemSerializer
class ItemViewSet(viewsets.ModelViewSet):
queryset = Item.objects.all().order_by('created_at', 'id')
serializer_class = ItemSerializer
Because the default is set globally, ItemViewSet inherits pagination without declaring pagination_class. The compound ordering ('created_at', 'id') gives each row a stable position so inserts and deletes are less likely to duplicate or skip rows between pages.
Response Envelope Clients Can Rely On
A successful list request returns something like:
{
"count": 150,
"next": "http://localhost:8000/api/items/?page=2",
"previous": null,
"results": [
{"id": 42, "name": "Widget"}
]
}
count is the total number of matching rows. next and previous are absolute URLs clients can follow directly. results holds the current page of serialized objects. Request GET /api/items/?page=2 to move forward.
Allow Client-Controlled Page Sizes, With a Cap
Sometimes clients need smaller or larger pages. Subclass the paginator and expose a query parameter, but always cap it:
from rest_framework.pagination import PageNumberPagination
class ItemPagination(PageNumberPagination):
page_size_query_param = 'page_size'
max_page_size = 100
Now GET /api/items/?page_size=50 works, but a request for 10,000 items is rejected. Without max_page_size, there is no upper bound and a client can force expensive queries. Assign the class to the viewset or to DEFAULT_PAGINATION_CLASS if it should apply everywhere.
Compare PageNumber, LimitOffset, and Cursor Pagination
| Style | Best for | Trade-off |
|---|---|---|
| PageNumberPagination | Human-readable pages, admin-style UIs | Uses COUNT and OFFSET; deep pages get slower |
| LimitOffsetPagination | Clients that need arbitrary jumps | Same deep-offset cost; exposes offset directly |
| CursorPagination | Infinite scroll over large tables | Opaque cursors; no page numbers; requires unique ordering |
PageNumberPagination runs COUNT(*) and OFFSET. As the offset grows, the database must scan and discard more rows. CursorPagination avoids that scan by remembering where the last page ended, but clients cannot jump to page 7 and old cursors break if the ordering changes. Pick the style that matches how clients actually traverse the list.
Worked Example: Configure, Call, and Verify
Add the paginator to settings.py or the viewset, then run this from your terminal (no special permissions required):
curl -i "http://localhost:8000/api/items/?page=2"
Expected check: HTTP 200 with count, next, previous, and results in the JSON body. Risk: requesting a page beyond the last page returns HTTP 404 by default in many DRF versions; confirm the exact status in your installed version before clients depend on it.
For an invalid page size, try:
curl -i "http://localhost:8000/api/items/?page_size=99999"
Expected check: HTTP 400 when max_page_size is enforced. If you see a different status, inspect your DRF version's paginator source.
Check installed versions with:
pip show djangorestframework django
Then compare the settings defaults in your installed package against the snippet above, because defaults and response details are version-sensitive.
Limitations and Practical Verification
Deep PageNumberPagination pages are expensive on large tables. Pair pagination with database indexes on the ordering columns, and prefer filtering so clients do not paginate through millions of rows they do not need.
Practical verification: write a test with a known dataset, assert count equals the total, assert the length of results matches the page size, request an invalid page, and record the actual error shape. Use assertNumQueries or Django Debug Toolbar to compare the query count between page 1 and a high page number.
Because pagination changes the response shape, update your OpenAPI schema, SDKs, and client code so no endpoint is accidentally consumed as both paginated and unpaginated.
Actionable Closing
Make pagination an explicit contract, not an afterthought. Set the default class and page size globally, cap client-controlled sizes, document the envelope, and choose PageNumberPagination for numbered pages or CursorPagination for large-scale infinite scroll. Verify with real requests and tests before clients depend on the behavior.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.