Introduction
Returning thousands of records in a single response is a recipe for slow APIs, crashed clients, and angry users. Pagination breaks large datasets into manageable chunks, improving performance for both server and client.
Pagination is essential for APIs returning large datasets. It improves performance and user experience.
Key Concepts
Page-Based Pagination: The most common approach, using ?page=2&per_page=20 to navigate through numbered pages.
Cursor-Based Pagination: Uses an opaque token to bookmark position, ideal for real-time feeds and infinite scrolling.
Offset-Based Pagination: Uses ?offset=40&limit=20 for fine-grained control over result position.
Django Paginator: Django's built-in Paginator class that handles page calculation and boundary checking.
Real World Context
Pagination is essential for:
- Social media feeds: Infinite scroll loading posts in batches
- E-commerce catalogs: Browsing products page by page
- Admin dashboards: Managing thousands of records without overwhelming the browser
- Search results: Displaying results in pages with navigation controls
Page-Based Pagination
The most common approach:
pythonfrom django.http import JsonResponse from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger def api_articles(request): # Get pagination parameters page = request.GET.get('page', 1) per_page = min(int(request.GET.get('per_page', 20)), 100) # Max 100 # Get queryset articles = Article.objects.all().order_by('-created_at') # Paginate paginator = Paginator(articles, per_page) try: page_obj = paginator.page(page) except PageNotAnInteger: page_obj = paginator.page(1) except EmptyPage: page_obj = paginator.page(paginator.num_pages) # Serialize articles_data = [{ 'id': a.id, 'title': a.title, 'created_at': a.created_at.isoformat() } for a in page_obj] return JsonResponse({ 'results': articles_data, 'pagination': { 'page': page_obj.number, 'per_page': per_page, 'total_pages': paginator.num_pages, 'total_count': paginator.count, 'has_next': page_obj.has_next(), 'has_previous': page_obj.has_previous(), } })
Response:
json{ "results": [...], "pagination": { "page": 2, "per_page": 20, "total_pages": 15, "total_count": 287, "has_next": true, "has_previous": true } }
The pagination metadata gives clients everything they need to build navigation controls: current position, total pages, and next/previous flags.
Offset-Based Pagination
Offset-based pagination uses ?offset=40&limit=20 instead of page numbers, giving clients fine-grained control over the starting position:
pythondef api_articles(request): offset = int(request.GET.get('offset', 0)) limit = min(int(request.GET.get('limit', 20)), 100) articles = Article.objects.all().order_by('-created_at') total = articles.count() articles_data = [{ 'id': a.id, 'title': a.title } for a in articles[offset:offset + limit]] return JsonResponse({ 'results': articles_data, 'offset': offset, 'limit': limit, 'total': total, 'has_more': offset + limit < total })
Offset pagination is simple but can skip or duplicate items when data changes between requests. For real-time feeds, consider cursor-based pagination instead.
Cursor-Based Pagination
Better for real-time data and infinite scrolling:
pythonimport base64 from django.http import JsonResponse def encode_cursor(pk, created_at): """Encode cursor as base64.""" cursor = f"{pk}:{created_at.isoformat()}" return base64.b64encode(cursor.encode()).decode() def decode_cursor(cursor): """Decode base64 cursor.""" decoded = base64.b64decode(cursor.encode()).decode() pk, created_at = decoded.split(':') return int(pk), created_at def api_articles(request): limit = min(int(request.GET.get('limit', 20)), 100) cursor = request.GET.get('cursor') articles = Article.objects.all().order_by('-created_at', '-id') if cursor: pk, created_at = decode_cursor(cursor) articles = articles.filter( created_at__lte=created_at ).exclude( created_at=created_at, id__gte=pk ) articles = list(articles[:limit + 1]) # Get one extra to check for more has_more = len(articles) > limit articles = articles[:limit] articles_data = [{ 'id': a.id, 'title': a.title, 'created_at': a.created_at.isoformat() } for a in articles] response = { 'results': articles_data, 'has_more': has_more } if articles and has_more: last = articles[-1] response['next_cursor'] = encode_cursor(last.id, last.created_at) return JsonResponse(response)
The cursor is a base64-encoded string containing the last item's ID and timestamp. Fetching one extra record (limit + 1) lets us determine if more pages exist without a separate COUNT query.
Pagination Class
A reusable pagination class encapsulates parameter parsing and page calculation, keeping your views clean:
pythonclass Paginator: """Reusable pagination class.""" def __init__(self, request, default_page_size=20, max_page_size=100): self.page = int(request.GET.get('page', 1)) self.page_size = min( int(request.GET.get('page_size', default_page_size)), max_page_size ) def paginate(self, queryset): from django.core.paginator import Paginator as DjangoPaginator paginator = DjangoPaginator(queryset, self.page_size) page_obj = paginator.get_page(self.page) return { 'items': list(page_obj), 'meta': { 'page': page_obj.number, 'page_size': self.page_size, 'total_pages': paginator.num_pages, 'total_count': paginator.count, } } # Usage def api_articles(request): paginator = Paginator(request) articles = Article.objects.all() result = paginator.paginate(articles) return JsonResponse({ 'results': [serialize_article(a) for a in result['items']], 'pagination': result['meta'] })
With this helper class, adding pagination to any view is a two-line operation: create the paginator and call paginate() on your queryset.
Common Pitfalls
-
No maximum page size: Allowing
?per_page=1000000lets clients crash your server. Always cap the maximum. -
COUNT queries on large tables:
paginator.countrunsSELECT COUNT(*)which can be slow on millions of rows. Consider estimating or caching. -
Offset pagination on changing data: If records are added/removed between page requests, items can be skipped or duplicated. Use cursor pagination for real-time data.
Best Practices
-
Cap page sizes: Set a maximum (e.g., 100) and enforce it server-side.
-
Include pagination metadata: Return total count, current page, total pages, and has_next/has_previous flags.
-
Use cursor pagination for real-time data: It handles insertions and deletions gracefully.
-
Default to sensible page sizes: 20-25 items per page is a good starting point.
Summary
Pagination prevents performance issues by breaking large datasets into pages. Use Django's built-in Paginator for page-based pagination, implement cursor-based pagination for real-time feeds, and always cap maximum page sizes. Include pagination metadata in responses so clients can navigate efficiently.
Code Examples
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.http import JsonResponse
def api_articles(request):
page = request.GET.get('page', 1)
per_page = min(int(request.GET.get('per_page', 20)), 100)
articles = Article.objects.all().order_by('-created_at')
paginator = Paginator(articles, per_page)
try:
page_obj = paginator.page(page)
except (PageNotAnInteger, EmptyPage):
page_obj = paginator.page(1)
return JsonResponse({
'results': [{'id': a.id, 'title': a.title} for a in page_obj],
'pagination': {
'page': page_obj.number,
'total_pages': paginator.num_pages,
'total_count': paginator.count,
}
})