jongkwan.dev
Development · Essay №007

Building Custom Pagination in Django

The limits of Django's built-in Paginator and a cursor-based custom pagination implementation

Jongkwan Lee2025년 3월 29일4 min read
Contents

This post covers the limits of Django's built-in Paginator and implements timestamp cursor-based custom pagination.

Understanding Django's built-in Paginator

Rendering a large volume of data at once makes response times long and memory usage high. Pagination splits data into fixed units and returns only one page worth per request.

The simplest way to paginate in Django is the Paginator class.

python
from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import render
from .models import Post
 
def post_list_view(request):
    post_qs = Post.objects.all().order_by('-created_at')
 
    # Create the Paginator object (10 items per page)
    paginator = Paginator(post_qs, 10)
 
    page = request.GET.get('page', 1)
 
    try:
        posts = paginator.page(page)
    except PageNotAnInteger:
        posts = paginator.page(1)
    except EmptyPage:
        posts = paginator.page(paginator.num_pages)
 
    return render(request, 'blog/post_list.html', {'posts': posts})

Key points

  • Paginator(queryset, per_page): takes a queryset and the per-page count as arguments
  • paginator.page(number): returns the Page object for that page
  • Exception handling: handle PageNotAnInteger and EmptyPage appropriately

Template example

html
{% for post in posts %}
  <h2>{{ post.title }}</h2>
  <p>{{ post.content }}</p>
{% endfor %}
 
<div class="pagination">
  {% if posts.has_previous %}
    <a href="?page={{ posts.previous_page_number }}">Previous</a>
  {% endif %}
 
  <span>{{ posts.number }} / {{ posts.paginator.num_pages }}</span>
 
  {% if posts.has_next %}
    <a href="?page={{ posts.next_page_number }}">Next</a>
  {% endif %}
</div>

When a custom Paginator is needed

The built-in Paginator handles most cases, but these situations call for a custom one:

  1. Using an identifier other than a page number (a hash, a date, a slug)
  2. Dynamic data loading for infinite scroll, where the next batch loads as the scroll reaches the end
  3. When paginate_by has to change dynamically
  4. Use alongside complex filter, search, and sort conditions

Customizing by subclassing Paginator

Pagination keyed on created_at

python
from django.core.paginator import Paginator
 
class CreatedAtPaginator(Paginator):
    """Custom Paginator keyed on created_at"""
 
    def __init__(self, object_list, per_page, **kwargs):
        super().__init__(object_list, per_page, **kwargs)
 
    def page_by_timestamp(self, timestamp):
        """Return up to per_page items older than timestamp"""
        if timestamp:
            filtered_qs = self.object_list.filter(created_at__lt=timestamp)
        else:
            filtered_qs = self.object_list
 
        return filtered_qs.order_by('-created_at')[:self.per_page]

created_at__lt=timestamp is the cursor that fetches data older than the last item of the previous page. Without a timestamp, this is treated as the first page and the whole set is cut to per_page items in newest-first order.

Using it from a view

page_by_timestamp returns a sliced queryset, not the Page object of the built-in Paginator. It therefore has no has_previous or has_next methods, and the cursor for the next page has to be computed and passed explicitly.

python
def post_list_by_timestamp(request):
    timestamp = request.GET.get('timestamp', None)
 
    post_qs = Post.objects.all()
    paginator = CreatedAtPaginator(post_qs, 10)
    posts = list(paginator.page_by_timestamp(timestamp))
 
    # The created_at of the last item becomes the cursor for the next request
    next_timestamp = posts[-1].created_at.isoformat() if posts else None
 
    return render(request, 'blog/post_list.html', {
        'posts': posts,
        'next_timestamp': next_timestamp,
    })

next_timestamp is the timestamp of the last item on this page, and sending it back as the ?timestamp= value on the next request continues with data older than that moment. The template uses this value for the next-page link instead of a page number.

html
{% for post in posts %}
  <h2>{{ post.title }}</h2>
{% endfor %}
 
{% if next_timestamp %}
  <a href="?timestamp={{ next_timestamp }}">Next</a>
{% endif %}

Django REST Framework pagination

Django REST Framework (DRF) provides class-based pagination. The three options are chosen as follows.

  • PageNumberPagination: page-number based. Suits ordinary lists that expose page numbers in the UI.
  • LimitOffsetPagination: uses limit and offset parameters. Use it when arbitrary ranges must be fetched.
  • CursorPagination: cursor based. Guarantees a consistent order at scale and avoids the skipped or duplicated rows of the offset approach.

A custom PageNumberPagination

python
from rest_framework.pagination import PageNumberPagination
 
class CustomPageNumberPagination(PageNumberPagination):
    page_size = 10
    page_size_query_param = 'page_size'  # allows ?page_size=20
    max_page_size = 100

Wiring it up

python
# settings.py - global configuration
REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'myapp.paginations.CustomPageNumberPagination',
    'PAGE_SIZE': 10,
}
 
# or on an individual ViewSet
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = CustomPageNumberPagination

Summary

ApproachUse case
Built-in PaginatorSimple page-number based lists
Custom PaginatorTime or slug based, special logic
DRF PaginationAPI responses, dynamic page_size

A simple list that needs page-number UI is fine with the built-in Paginator. When the identifier is not a page number, or the flow appends results as with infinite scroll, a cursor-based custom approach such as a timestamp is the right fit. The cursor approach guarantees a consistent order with no gaps or duplicates even when data is added or deleted in the middle. The cost is that it cannot jump straight to an arbitrary page. For API responses or a dynamic page_size, use the DRF pagination classes, and prefer CursorPagination for large sorted result sets.