Mastering Django Pagination: The Complete Guide to Performance and Scalability

When building a web application or an API with Django, use this link early development is often characterized by small datasets. Your database has a dozen records, queries return instantly, and everything feels lightning fast. But as your application grows to tens of thousands—or millions—of rows, reality sets in.

Loading an entire database table into memory to render a template or return a JSON response will rapidly choke your server, spike memory usage, and cause request timeouts.

The antidote to this performance bottleneck is Pagination.

In this comprehensive guide, we will explore how pagination works in both traditional Django template-based views and modern Django REST Framework (DRF) APIs, examine built-in pagination styles, and review best practices for high-performance applications.

Why Pagination is Non-Negotiable

Pagination is the practice of dividing a large dataset into discrete, manageable chunks (or “pages”) delivered to the client on demand. Without it, you face three major risks:

  1. Memory Exhaustion: Fetching thousands of ORM objects and serializing them simultaneously consumes massive RAM on your application server.
  2. Network Latency: Transferring megabytes of JSON data over the network degrades client-side performance, especially on mobile devices with constrained bandwidth.
  3. Database Strain: Large SQL queries without limits or offsets force database engines to scan entire tables, locking resources and destroying query efficiency.

Fortunately, Django provides powerful tools out of the box to solve this at every level of your stack.

Part 1: Pagination in Vanilla Django (Template-Based Views)

For server-side rendered applications using Django templates, Django provides a built-in Paginator class inside django.core.paginator.

1. Implementing the Paginator in a View

Imagine a blog application where we want to display 10 articles per page. Here is how you implement the paginator in a standard function-based view:

Python

from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger
from django.shortcuts import render
from .models import Article

def article_list_view(request):
    article_list = Article.objects.all().order_by('-created_at')
    
    # Show 10 articles per page
    paginator = Paginator(article_list, 10) 
    
    # Get the requested page number from URL query params (e.g., ?page=2)
    page_number = request.GET.get('page')
    
    try:
        articles = paginator.get_page(page_number)
    except PageNotAnInteger:
        # If page is not an integer, deliver the first page
        articles = paginator.page(1)
    except EmptyPage:
        # If page is out of range (e.g. page 9999), deliver last page of results
        articles = paginator.page(paginator.num_pages)

    return render(request, 'articles/article_list.html', {'articles': articles})

Note: Using paginator.get_page(page_number) is generally preferred over paginator.page() because it automatically handles invalid or out-of-bounds page numbers gracefully.

2. Rendering Pagination Links in the Template

Once the Page object is passed to your template, you can easily build navigation controls:

HTML

<div class="pagination">
    <span class="step-links">
        {% if articles.has_previous %}
            <a href="?page=1">&laquo; first</a>
            <a href="?page={{ articles.previous_page_number }}">previous</a>
        {% endif %}

        <span class="current">
            Page {{ articles.number }} of {{ articles.paginator.num_pages }}.
        </span>

        {% if articles.has_next %}
            <a href="?page={{ articles.next_page_number }}">next</a>
            <a href="?page={{ articles.paginator.num_pages }}">last &raquo;</a>
        {% endif %}
    </span>
</div>

Part 2: Pagination in Django REST Framework (DRF)

When building APIs for frontend frameworks (like React, browse around these guys Vue, or mobile apps), pagination needs to be serialized into clean JSON structures containing metadata (like count, next, and previous URLs).

DRF provides three primary built-in pagination classes.

1. PageNumberPagination (The Standard Approach)

This is the most common pagination style, mirroring traditional web pagination. Clients request data by page number and page size.

Global Configuration (settings.py):

Python

REST_FRAMEWORK = {
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 20, # Default number of items per page
}

Customizing Page Size Per View:

If a specific endpoint needs a different page size, you can create a custom pagination subclass:

Python

from rest_framework.pagination import PageNumberPagination

class StandardResultsSetPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = 'page_size' # Allows client to pass ?page_size=50
    max_page_size = 100 # Prevents clients from requesting 10,000 items at once

class ArticleViewSet(viewsets.ModelViewSet):
    queryset = Article.objects.all()
    serializer_class = ArticleSerializer
    pagination_class = StandardResultsSetPagination

When a client queries /api/articles/?page=2, DRF returns a structured JSON response:

JSON

{
    "count": 145,
    "next": "http://api.example.com/api/articles/?page=3",
    "previous": "http://api.example.com/api/articles/?page=1",
    "results": [
        {
            "id": 21,
            "title": "Understanding Django ViewSets",
            "author": "Jane Doe"
        }
        // ... 19 more items
    ]
}

2. LimitOffsetPagination (The SQL-Style Approach)

Inspired by SQL LIMIT and OFFSET clauses, this style allows clients to specify exactly where to start slicing the dataset and how many items to return.

  • limit: The maximum number of items to return (e.g., ?limit=20).
  • offset: The starting index from which to begin the slice (e.g., offset=40 starts at the 41st item).

Python

from rest_framework.pagination import LimitOffsetPagination

class ArticleLimitOffsetPagination(LimitOffsetPagination):
    default_limit = 20
    max_limit = 100

This style is particularly useful for client-side applications that implement “load more” buttons or custom slider components where absolute page numbers matter less than contiguous blocks of data.

3. CursorPagination (The High-Performance Infinite Scroll)

Standard page-number and offset pagination suffer from a major performance flaw at scale: deep pagination penalty.

When a database executes an OFFSET 100000 LIMIT 20 query, it still has to read and discard the first 100,000 rows before returning the 20 you requested. As your table grows, deep pages become exponentially slower.

CursorPagination solves this by using an opaque, encoded string (a cursor) pointing to a specific database record based on an ordered field (like timestamp or ID).

Python

from rest_framework.pagination import CursorPagination

class ArticleCursorPagination(CursorPagination):
    page_size = 20
    ordering = '-created_at' # Must be a unique or indexed field
    cursor_query_param = 'cursor'

Why Choose Cursor Pagination?

  • Constant Performance: Query execution time remains lightning-fast whether you are on page 2 or page 10,000.
  • No Duplicate/Missing Records: If new rows are inserted while a user is scrolling through an infinite feed, traditional page numbers shift and can cause items to repeat or disappear. Cursor pagination stays locked to the pointer.

Summary Comparison: Choosing the Right Strategy

Pagination ClassBest Used ForClient Query ExamplePerformance at Scale
PageNumberPaginationStandard web apps, admin panels, predictable navigation.?page=2&page_size=20Degrades on extremely deep pages ($>10,000$ pages).
LimitOffsetPaginationCustom desktop/mobile UI sliders, infinite scroll with explicit jumps.?limit=20&offset=40Degrades heavily with large offsets.
CursorPaginationHigh-throughput feeds, real-time logs, infinite scrolling.?cursor=cD0yMDI2...Extremely fast and constant regardless of depth.

Production Best Practices for Pagination

  1. Always Set a Maximum Page Size: Never leave max_page_size unrestricted. Malicious or accidental queries requesting ?page_size=100000 can instantly crash your database server.
  2. Ensure Proper Database Indexing: If you are sorting or filtering by specific fields (like created_at or price), ensure those columns have database indexes (db_index=True) to prevent full table scans during pagination.
  3. Combine with Select Related: When serializing related foreign key objects across paginated results, always optimize your queryset with .select_related() and .prefetch_related() to eliminate $N+1$ query issues.

Conclusion

Pagination is a foundational pillar of building scalable, production-ready Django applications. Whether you are rendering server-side templates with Django’s built-in Paginator or serving high-speed JSON feeds using Django REST click here for more info Framework’s cursor and page-number engines, understanding how to control data flow protects your server resources and delivers a seamless user experience.