DRF Pagination

5 interview angles 3 min read source

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:

  • 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

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