Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 30

API Pagination Strategies

Recommended resources

Hello! Welcome back to our backend engineering journey.

In the last lesson, we significantly streamlined our API by refactoring our views into a single ModelViewSet and using a DefaultRouter to handle URL generation. This is a huge productivity win, but it leaves us with a potential scaling problem. Our /api/tasks/ endpoint currently fetches and returns every single task from the database. This is fine for a few dozen tasks, but what happens when your application has thousands or even millions?

The answer is poor performance. A massive database query, a huge JSON payload, and high memory usage on both the server and the client can cripple your application. Today, we'll solve this critical issue. The goal for this lesson is to implement pagination for API responses to handle large datasets. You'll learn how to break up large result sets into smaller, manageable "pages," ensuring your API remains fast, responsive, and scalable.

1. Why Paginate? The Core Problem

Before we write any code, it's crucial to understand why we're doing this. Sending all data in one go is inefficient for several reasons:

  • Database Strain: Fetching millions of rows can be a slow operation, putting a heavy load on your database.
  • Network Latency: Transferring a multi-megabyte JSON response over the internet takes time, leading to a slow user experience.
  • Client-Side Performance: A web browser or mobile app trying to parse and render a massive list of items can become unresponsive or even crash.

Pagination is the standard solution. Instead of returning everything, the API returns a "page" of data (e.g., the first 20 items) along with links or information on how to fetch the next page.

Django REST Framework Pagination Types
This diagram shows the core concept of splitting data into pages and lists the primary pagination styles available in Django REST Framework: PageNumber, LimitOffset, and Cursor.

2. Understanding Pagination Strategies

DRF offers several built-in pagination strategies, but they largely fall into two conceptual categories: offset-based and cursor-based. Understanding the trade-offs is key to choosing the right tool for the job.

Implementing Diverse Pagination Strategies in DRF and ...

To grasp the fundamental differences between these approaches, please read the introductory sections of the article 'Implementing Diverse Pagination Strategies in DRF and ...' from Leapcell.

Read the sections titled 'Introduction', 'Core Pagination Concepts', 'Limit/Offset Pagination: Simplicity and Its Pitfalls', 'Cursor-based Pagination: Ensuring Stability and Performance', and 'Choosing the Right Strategy'. Focus on the advantages and disadvantages of each method. This will give you the theoretical foundation for the practical work to follow.

As you read, here are the key distinctions to note:

  • Offset-based Pagination (PageNumber & LimitOffset):

    • How it works: It uses a limit (how many items per page) and an offset (how many items to skip). This is like saying, "skip the first 50 items and give me the next 10."
    • Pro: Simple and intuitive. Allows clients to jump to any page (e.g., page 5 of 10).
    • Con: Can be inefficient. To get to page 1000, the database might still need to scan and count the 9990 items it's skipping. It can also be "unstable"—if new items are added while a user is paginating, they might see duplicates or miss items between pages.
  • Cursor-based Pagination (CursorPagination):

    • How it works: It uses a "cursor," which is an opaque pointer to the last item you saw. The next request says, "give me 10 items after this specific item."
    • Pro: Highly performant and scalable, even with millions of rows, because the database can use an index to jump directly to the starting point. It's also stable in the face of new data being added.
    • Con: More complex. It doesn't allow jumping to an arbitrary page number, only "next" or "previous." This makes it perfect for "infinite scroll" feeds but less suitable for a classic numbered page interface.

For most standard APIs, PageNumberPagination and LimitOffsetPagination are sufficient and easy to implement. We will focus on these two today.


3. Implementing Global PageNumberPagination

The easiest way to enable pagination is globally for your entire project. Let's start with PageNumberPagination, which is the most common style.

API Pagination - Django REST Framework | PageNumberPagination & LimitOffsetPagination

The video 'API Pagination - Django REST Framework' by BugBytes provides an excellent, step-by-step guide to implementing both PageNumber and LimitOffset pagination.

Please watch from the beginning until 04:20. This will cover: The motivation for pagination (00:00 - 01:02). How to enable PageNumberPagination globally in settings.py (02:33 - 03:17). How this changes the API response structure (03:17 - 04:20).

Following the video's example, let's configure your project.

  1. Open your core/settings.py file.
  2. Find the REST_FRAMEWORK dictionary and add the pagination configuration.
# core/settings.py

REST_FRAMEWORK = {
    # ... other settings
    'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10  # Number of objects to return per page
}

And that's it! Just by adding these two lines, pagination is now enabled for any list view that uses DRF's generic views or ViewSets, including your TaskViewSet.

Now, run your development server and navigate to http://127.0.0.1:8000/api/tasks/. You'll notice the response format has completely changed. Instead of a simple list of tasks, you get a JSON object with these keys:

  • count: The total number of tasks in the database.
  • next: A URL to the next page of results (or null if you're on the last page).
  • previous: A URL to the previous page (or null if you're on the first page).
  • results: A list containing the 10 tasks for the current page.
Django REST Framework Pagination Example
This image shows an example of a paginated response in the DRF browsable API. Notice the `count`, `next`, `previous`, and `results` fields, as well as the pagination controls at the bottom.

This structure provides the client with everything it needs to navigate through the dataset page by page.

A Note on Ordering

When you refresh your API, you might see a warning in your terminal: pagination may yield inconsistent results with an unordered object list. This is the instability issue we discussed earlier. To fix this and ensure consistent pagination, you should always apply a default ordering to your queryset.

Update your TaskViewSet in api/views.py:

# api/views.py

class TaskViewSet(viewsets.ModelViewSet):
    # ...
    # Order by creation date, newest first. The '-' indicates descending order.
    queryset = Task.objects.all().order_by('-created_at') 
    serializer_class = TaskSerializer

This ensures your tasks are always returned in a predictable order, making pagination reliable.


4. Customizing Pagination Behavior

While global settings are great, real-world applications often require more flexibility. You might want to let the client specify the page size or apply different pagination rules to different endpoints.

Allowing Client-Side Page Size Control

DRF makes it easy to let the client control the page size via a query parameter (e.g., ?size=50). However, you must set a maximum limit to prevent a malicious user from requesting a huge number of records (e.g., ?size=1000000) and overloading your server.

Let's create a custom pagination class to encapsulate these rules.

  1. Create a new file in your api app: api/pagination.py.
  2. Define a custom pagination class inside it.
# api/pagination.py
from rest_framework.pagination import PageNumberPagination

class StandardResultsSetPagination(PageNumberPagination):
    page_size = 10  # Default page size
    page_size_query_param = 'size'  # Client can override the page size using this query param
    max_page_size = 100  # Maximum page size the client can request

Now, instead of using DRF's default class, you can point your global settings to your new custom class.

  1. Update core/settings.py:
# core/settings.py

REST_FRAMEWORK = {
    # Change this to point to your new custom class
    'DEFAULT_PAGINATION_CLASS': 'api.pagination.StandardResultsSetPagination',
    # PAGE_SIZE can be removed from here, as it's defined in our class now.
}

Now, if you go to http://127.0.0.1:8000/api/tasks/?size=5, you will see only 5 results. If you try ?size=200, DRF will cap the result at max_page_size (100), protecting your API.

Applying Pagination Per-View

You can also override the global pagination settings for a specific ViewSet. Simply set the pagination_class attribute on the view.

# api/views.py
from .pagination import StandardResultsSetPagination # Import your class

class TaskViewSet(viewsets.ModelViewSet):
    # ...
    queryset = Task.objects.all().order_by('-created_at') 
    serializer_class = TaskSerializer
    pagination_class = StandardResultsSetPagination # Apply this pagination class

This approach is useful when you have different requirements for different endpoints (e.g., a "large" page size for an admin endpoint and a "small" one for a mobile endpoint).

Test your understanding!
  1. You are building an API for a social media feed where new posts are added constantly, and users use a "Load More" button to see older posts. Which pagination style (PageNumber or Cursor) would be more appropriate, and why? What potential problem could you face with the less appropriate style?

  2. A new developer on your team suggests removing the max_page_size setting from your custom pagination class to give clients "maximum flexibility." Why is this a bad idea from a backend engineering perspective?

Show answer
  1. CursorPagination is more appropriate. Because new posts are constantly being added, the dataset is highly dynamic. Cursor pagination is stable and ensures a user won't see duplicate posts or miss any as they scroll, because it fetches items relative to the last one seen. If you used PageNumber pagination, a user on page 2 might see a post that was also on page 1 if new posts were added after they loaded the first page.

  2. Removing max_page_size is a significant security and performance risk. It would allow any client to request an unlimited number of records in a single API call (e.g., ?size=9999999). This could easily overload the database, consume excessive server memory, and be used as a simple Denial-of-Service (DoS) attack vector to bring down your application. As a backend engineer, it is your responsibility to enforce sensible limits to protect your system's resources.


5. A Quick Look at LimitOffsetPagination

The other common offset-based style is LimitOffsetPagination. This style might feel more familiar if you've worked with SQL databases directly.

  • limit: The number of items to return.
  • offset: The starting point in the dataset.

To try it out, you can change your DEFAULT_PAGINATION_CLASS in settings.py:
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination'

Your API requests would then look like .../api/tasks/?limit=10&offset=20, which would return 10 tasks after skipping the first 20. It serves a similar purpose to PageNumberPagination but gives the client more direct control over the limit and offset, which can be useful in some scenarios.


Conclusion

You've now equipped your API with one of the most fundamental features for building scalable, production-ready systems. By implementing pagination, you ensure that your API can handle growing datasets gracefully without sacrificing performance.

Key Takeaways:

  • Pagination is essential for preventing performance bottlenecks when dealing with large datasets.
  • DRF provides three main pagination styles: PageNumberPagination, LimitOffsetPagination, and CursorPagination.
  • Offset-based styles (PageNumber, LimitOffset) are simple and allow jumping to specific pages but can be inefficient and unstable with very large, dynamic datasets.
  • Cursor-based pagination is highly performant and stable but restricts navigation to "next/previous" links, making it ideal for infinite-scroll feeds.
  • You can enable pagination globally in settings.py or on a per-view basis.
  • Always use settings like max_page_size to protect your API from being overwhelmed by large requests.

Preview of the Next Lesson:
Your users can now browse through pages of tasks, but what if they want to find a specific task or see only the "completed" ones? In our next lesson, we will build on our paginated list by adding powerful filtering, searching, and ordering capabilities to our API endpoints, giving clients fine-grained control over the data they retrieve.