When building APIs that return large datasets, pagination is essential for performance and user experience. Without it, your API could return thousands of records in a single response, causing slow load times and memory issues.
In this guide, we'll explore all pagination strategies available in Django REST Framework (DRF) and when to use each one.
Consider an e-commerce API returning all products:
# Without pagination - BAD class ProductListView(APIView): def get(self, request): products = Product.objects.all() # Could be 100,000+ records! serializer = ProductSerializer(products, many=True) return Response(serializer.data)
Problems with this approach:
First, let's configure pagination globally in your Django settings:
# settings.py REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 20, }
The most common and intuitive pagination style. Users navigate using page numbers like ?page=2.
# pagination.py from rest_framework.pagination import PageNumberPagination class StandardResultsSetPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'page_size' max_page_size = 100
# views.py from rest_framework import generics from .pagination import StandardResultsSetPagination class ProductListView(generics.ListAPIView): queryset = Product.objects.all() serializer_class = ProductSerializer pagination_class = StandardResultsSetPagination
{ "count": 1000, "next": "http://api.example.com/products/?page=2", "previous": null, "results": [ {"id": 1, "name": "Product 1", "price": 29.99}, {"id": 2, "name": "Product 2", "price": 49.99} ] }
# pagination.py from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response class CustomPageNumberPagination(PageNumberPagination): page_size = 20 page_size_query_param = 'limit' max_page_size = 100 def get_paginated_response(self, data): return Response({ 'pagination': { 'total_items': self.page.paginator.count, 'total_pages': self.page.paginator.num_pages, 'current_page': self.page.number, 'page_size': self.get_page_size(self.request), 'has_next': self.page.has_next(), 'has_previous': self.page.has_previous(), }, 'links': { 'next': self.get_next_link(), 'previous': self.get_previous_link(), }, 'results': data })
Response with custom format:
{ "pagination": { "total_items": 1000, "total_pages": 50, "current_page": 1, "page_size": 20, "has_next": true, "has_previous": false }, "links": { "next": "http://api.example.com/products/?page=2", "previous": null }, "results": [...] }
Similar to SQL's LIMIT and OFFSET. Users specify how many records to skip and how many to return: ?limit=20&offset=40.
# pagination.py from rest_framework.pagination import LimitOffsetPagination class StandardLimitOffsetPagination(LimitOffsetPagination): default_limit = 20 limit_query_param = 'limit' offset_query_param = 'offset' max_limit = 100
{ "count": 1000, "next": "http://api.example.com/products/?limit=20&offset=40", "previous": "http://api.example.com/products/?limit=20&offset=0", "results": [...] }
# LimitOffset becomes slow with large offsets # This query is inefficient: Product.objects.all()[100000:100020] # Scans 100,000 rows first!
The most performant option for large datasets. Uses an opaque cursor string instead of page numbers.
PageNumber/LimitOffset: O(n) - Gets slower with higher pages Cursor Pagination: O(1) - Constant time regardless of position
# pagination.py from rest_framework.pagination import CursorPagination class ProductCursorPagination(CursorPagination): page_size = 20 ordering = '-created_at' # Must be a unique, sequential field cursor_query_param = 'cursor'
{ "next": "http://api.example.com/products/?cursor=cD0yMDIzLTA1LTEwKzEyJTNBMzAlM0EwMA%3D%3D", "previous": null, "results": [...] }
# The ordering field must be: # 1. Unique or nearly unique (timestamps work well) # 2. Sequential (sortable) # 3. Immutable (shouldn't change after creation) class ProductCursorPagination(CursorPagination): ordering = '-created_at' # Good: timestamp is sequential # ordering = 'name' # Bad: not unique, not sequential
| Feature | PageNumber | LimitOffset | Cursor |
|---|---|---|---|
| Performance (large data) | Poor | Poor | Excellent |
| Jump to specific page | Yes | Yes | No |
| Consistent with data changes | No | No | Yes |
| SEO friendly | Yes | Partial | No |
| Infinite scroll | Partial | Good | Best |
| Implementation complexity | Low | Low | Medium |
Sometimes you need different pagination for different scenarios:
# pagination.py from rest_framework.pagination import PageNumberPagination, CursorPagination class DynamicPagination: """ Returns different pagination based on query params or data size """ @staticmethod def get_pagination_class(request, queryset): # Use cursor pagination for large datasets if queryset.count() > 10000: return CursorPagination # Use page number for smaller datasets return PageNumberPagination
# views.py from rest_framework import generics from .pagination import DynamicPagination class ProductListView(generics.ListAPIView): queryset = Product.objects.all() serializer_class = ProductSerializer def get_pagination_class(self): return DynamicPagination.get_pagination_class( self.request, self.get_queryset() )
class ProductListView(generics.ListAPIView): def get_queryset(self): return Product.objects.select_related( 'category', 'brand' ).prefetch_related( 'tags', 'images' )
# models.py class Product(models.Model): name = models.CharField(max_length=255) created_at = models.DateTimeField(auto_now_add=True, db_index=True) price = models.DecimalField(max_digits=10, decimal_places=2, db_index=True) class Meta: indexes = [ models.Index(fields=['created_at', 'id']), # For cursor pagination models.Index(fields=['-price', 'name']), # For sorted queries ]
class ProductListSerializer(serializers.ModelSerializer): class Meta: model = Product fields = ['id', 'name', 'price', 'thumbnail'] # Only essential fields class ProductDetailSerializer(serializers.ModelSerializer): class Meta: model = Product fields = '__all__' # All fields for detail view
# tests.py from rest_framework.test import APITestCase from rest_framework import status class ProductPaginationTest(APITestCase): def setUp(self): # Create 50 test products for i in range(50): Product.objects.create( name=f'Product {i}', price=i * 10 ) def test_pagination_returns_correct_page_size(self): response = self.client.get('/api/products/') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data['results']), 20) def test_pagination_returns_total_count(self): response = self.client.get('/api/products/') self.assertEqual(response.data['count'], 50) def test_custom_page_size(self): response = self.client.get('/api/products/?page_size=10') self.assertEqual(len(response.data['results']), 10) def test_page_navigation(self): response = self.client.get('/api/products/?page=2') self.assertIsNotNone(response.data['previous']) self.assertIsNotNone(response.data['next'])
Choosing the right pagination strategy depends on your use case:
Remember to:
select_related and prefetch_relatedImplementing proper pagination will significantly improve your API's performance and user experience.