DRF Pagination
Choose the right pagination style — PageNumber, LimitOffset or Cursor — and know why Cursor wins at scale.
An endpoint that returns every row works fine with 50 records and falls over at 5 million. Pagination is not optional on a real API. DRF ships three styles, and picking the right one is a scaling decision, not a cosmetic one.
| Style | Best for | Weakness |
|---|---|---|
PageNumberPagination | Small/medium sets, page-number UIs | Deep pages are slow (large OFFSET) |
LimitOffsetPagination | Flexible windows, ‘load 20 from N’ | Same OFFSET cost; items shift if data changes |
CursorPagination | Large, frequently-changing feeds | No jump-to-page; needs a stable ordering |
Set a global default, override per view
# settings.py
REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 20,
}Why cursor pagination matters: offset-based paging does OFFSET 100000 LIMIT 20, which forces the database to walk and discard 100,000 rows — slower the deeper you go. Cursor pagination instead says WHERE created < last_seen ORDER BY created LIMIT 20, which uses an index and stays fast at any depth. It also won't skip or duplicate rows when new items are inserted mid-scroll — exactly what you want for an infinite feed.
Example
from rest_framework.pagination import CursorPagination, PageNumberPagination
from rest_framework import generics
class StandardResultsPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size' # let clients ask for more...
max_page_size = 100 # ...but cap it to protect the DB
class ActivityFeedPagination(CursorPagination):
page_size = 30
ordering = '-created' # MUST be an indexed, stable ordering
cursor_query_param = 'cursor'
class ActivityFeedView(generics.ListAPIView):
serializer_class = ActivitySerializer
pagination_class = ActivityFeedPagination # per-view override
def get_queryset(self):
return (
Activity.objects
.filter(user=self.request.user)
.select_related('actor', 'target')
.order_by('-created', '-id') # tiebreaker keeps the cursor stable
)
# A cursor response is opaque and cheap to page forever:
# {
# "next": "https://api/feed/?cursor=cD0yMDI2LTA3",
# "previous": null,
# "results": [ ... 30 items ... ]
# }
# No 'count' is issued — cursor pagination skips the expensive COUNT(*) too.When to use it
- A developer configures PageNumberPagination globally so every DRF list endpoint returns 20 items per page.
- A team uses CursorPagination for a real-time feed so clients can scroll forward without page-number drift.
- A developer writes a custom LimitOffsetPagination subclass to cap the maximum page size at 100 records.
More examples
PageNumberPagination globally
Applies page-number pagination with 20 items per page to every DRF list endpoint by default.
# settings.py
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}Custom pagination class
Defines a reusable pagination class with a configurable page_size query parameter capped at 50.
from rest_framework.pagination import PageNumberPagination
class SmallResultsSetPagination(PageNumberPagination):
page_size = 5
page_size_query_param = "page_size"
max_page_size = 50
class PostListView(ListAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = SmallResultsSetPaginationCursorPagination for feeds
Uses cursor-based pagination for a chronological feed to avoid inconsistencies from new insertions.
from rest_framework.pagination import CursorPagination
class FeedCursorPagination(CursorPagination):
page_size = 10
ordering = "-published_at"
class FeedView(ListAPIView):
queryset = Post.objects.filter(draft=False)
serializer_class = PostSerializer
pagination_class = FeedCursorPagination
Discussion