DRF Pagination

Updated 5 interview angles 4 min read source
On this page12
  1. The three paginators
  2. Global default
  3. PageNumberPagination
  4. LimitOffsetPagination
  5. CursorPagination — the right choice for large datasets
  6. Per-view pagination override
  7. Pagination + filtering + ordering interaction
  8. Custom envelope
  9. Pagination in custom actions
  10. Common pitfalls
  11. Related
  12. Interview angle

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

python
# 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

python
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:

json
{
  "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:

python
def get_paginated_response(self, data):
    return Response({"results": data, "next": ..., "previous": ...})  # drop count

LimitOffsetPagination

python
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

python
from rest_framework.pagination import CursorPagination

class FeedPagination(CursorPagination):
    page_size = 50
    # MUST be a unique-ish, stable, indexed field
    ordering = "-created_at"
    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:

json
{
  "next": "https://api.example.com/feed/?cursor=cD0yMDI0LTAx...",
  "previous": null,
  "results": [...]
}

Constraints:

  • ordering must 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

python
class BookViewSet(viewsets.ModelViewSet):
    # override the global default
    pagination_class = FeedPagination

class BookExportViewSet(viewsets.ModelViewSet):
    # disable for export endpoints
    pagination_class = None

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:

python
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:

python
@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_size not set → a client requesting ?page_size=10000000 causes OOM.

Interview angle 5

  • “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 OFFSET pagination slow on large tables?” — Postgres still scans+discards the offset rows; cost grows linearly with offset. Cursor uses a WHERE predicate 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_size to prevent OOM from a malicious client?” — max_page_size on the paginator class.
  • “Does count always require a query?” — yes, SELECT COUNT(*) FROM filtered_qs. On huge tables, override get_paginated_response to drop it or use cursor instead.