DRF Pagination
Pagination caps the size of list responses. DRF ships three styles — pick by the access pattern, not by personal taste.
The three paginators
| Class | Query params | Best for |
|---|---|---|
PageNumberPagination |
?page=3&page_size=50 |
UIs with page numbers, small/medium datasets, stable ordering |
LimitOffsetPagination |
?limit=50&offset=100 |
“Load more” UIs, simple slicing |
CursorPagination |
?cursor=cD0yMDIz... |
Large/append-only datasets, real-time feeds, deep pagination |
Performance ranking on large tables: Cursor ≫ Page ≈ Limit/Offset (both are OFFSET N LIMIT M under the hood, which scans + discards N rows).
Global default
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 50,
}
Without DEFAULT_PAGINATION_CLASS, DRF returns the entire queryset every time — common cause of timeouts on a growing table.
PageNumberPagination
from rest_framework.pagination import PageNumberPagination
class StandardPagination(PageNumberPagination):
page_size = 50
page_size_query_param = "page_size" # let clients override
max_page_size = 200 # cap it so they can't ask for 1M
Response shape:
{
"count": 1283,
"next": "https://api.example.com/books/?page=4",
"previous": "https://api.example.com/books/?page=2",
"results": [...]
}
count requires a SELECT COUNT(*) — on a table with 50M rows that’s seconds. Override:
def get_paginated_response(self, data):
return Response({"results": data, "next": ..., "previous": ...}) # drop count
LimitOffsetPagination
class StandardPagination(LimitOffsetPagination):
default_limit = 50
max_limit = 200
Same OFFSET N problem on large tables — OFFSET 100000 LIMIT 50 reads 100050 rows and throws away 100000.
CursorPagination — the right choice for large datasets
from rest_framework.pagination import CursorPagination
class FeedPagination(CursorPagination):
page_size = 50
ordering = "-created_at" # MUST be a unique-ish, stable, indexed field
cursor_query_param = "cursor"
How it works: instead of OFFSET, it does WHERE created_at < <last_seen> ORDER BY created_at DESC LIMIT 51. Constant-time regardless of depth. The cursor encodes the last-seen value.
Response:
{
"next": "https://api.example.com/feed/?cursor=cD0yMDI0LTAx...",
"previous": null,
"results": [...]
}
Constraints:
orderingmust be on a column that’s indexed and (close to) unique. Ties cause skipped or duplicated rows.- No “jump to page N” — only forward/back from the current cursor.
- No
count.
For real-time feeds and any list >10k rows, this is the right default.
Per-view pagination override
class BookViewSet(viewsets.ModelViewSet):
pagination_class = FeedPagination # override the global default
class BookExportViewSet(viewsets.ModelViewSet):
pagination_class = None # disable for export endpoints
Pagination + filtering + ordering interaction
The order in GenericAPIView.list() is: get_queryset() → filter_queryset() → paginate_queryset() → get_serializer(). So filters narrow the queryset before pagination counts/slices. That’s correct; just remember count reflects the filtered total.
Custom envelope
To match a frontend that expects data + meta:
class StandardPagination(PageNumberPagination):
page_size = 50
def get_paginated_response(self, data):
return Response({
"data": data,
"meta": {
"page": self.page.number,
"total_pages": self.page.paginator.num_pages,
"total": self.page.paginator.count,
},
})
Pagination in custom actions
@action methods don’t auto-paginate — call manually:
@action(detail=False)
def recent(self, request):
qs = self.get_queryset().filter(published=True)
page = self.paginate_queryset(qs)
if page is not None:
serializer = self.get_serializer(page, many=True)
return self.get_paginated_response(serializer.data)
return Response(self.get_serializer(qs, many=True).data)
Common pitfalls
- No global
DEFAULT_PAGINATION_CLASS→ list endpoints return everything. Set one as the safe default. - Page-based pagination on a 10M-row table → COUNT(*) is slow, OFFSET deep is slow. Switch to cursor.
- Cursor pagination ordered by a non-unique field → ties cause inconsistent paging. Add a tiebreaker:
ordering = ("-created_at", "-id"). max_page_sizenot set → a client requesting?page_size=10000000causes OOM.
Interview angle
- “Three pagination styles in DRF — when do you pick each?” — page numbers for small bounded sets and admin UIs; limit/offset for “load more”; cursor for large or real-time data.
- “Why is
OFFSETpagination slow on large tables?” — Postgres still scans+discards the offset rows; cost grows linearly with offset. Cursor uses aWHEREpredicate against an indexed column. - “What’s the downside of cursor pagination?” — no jump-to-page-N, no total count, requires a unique stable ordering field.
- “How would you cap
page_sizeto prevent OOM from a malicious client?” —max_page_sizeon the paginator class. - “Does
countalways require a query?” — yes,SELECT COUNT(*) FROM filtered_qs. On huge tables, overrideget_paginated_responseto drop it or use cursor instead.