Why Every Django List API Should Use Pagination


When building an API, it’s tempting to return every record from the database. During development, this usually works because there are only a few rows.

The real challenge appears when your application starts serving real users.

Imagine your Product table grows from 100 records to 500,000 records. An endpoint that once returned data in milliseconds may suddenly become slow, consume more memory, and increase database load.

This is where pagination becomes essential.

What Is Pagination?

Pagination is the process of splitting a large dataset into smaller chunks (pages) instead of returning everything at once.

Instead of returning 10,000 records:

GET /api/products

You return a limited number of records:

GET /api/products?page=1&page_size=20

The client can then request additional pages when needed.

Why Pagination Matters

Pagination offers several benefits:

  • Reduces database load
  • Improves API response time
  • Uses less server memory
  • Reduces bandwidth consumption
  • Improves the user experience
  • Makes APIs more scalable

Without pagination, every request becomes more expensive as your data grows.

A Simple Example

Suppose you have this Django model:

class Product(models.Model):
    name = models.CharField(max_length=200)
    price = models.DecimalField(max_digits=10, decimal_places=2)
    created_at = models.DateTimeField(auto_now_add=True)

A common mistake is returning every product:

products = Product.objects.all()

This may work today, but if your database contains hundreds of thousands of rows, every request will fetch all of them.

That’s rarely what the client actually needs.

Using Django REST Framework Pagination

Django REST Framework makes pagination straightforward.

Global Pagination

In settings.py:

REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

Now every list endpoint automatically returns paginated results.

Example response:

{
    "count": 250,
    "next": "http://localhost:8000/api/products/?page=2",
    "previous": null,
    "results": [
        ...
    ]
}

Different Pagination Styles

Django REST Framework supports multiple pagination strategies.

1. Page Number Pagination
GET /api/products?page=3

Simple and easy to understand.

Best for:

  • Admin panels
  • Dashboards
  • Small and medium-sized applications

2. Limit Offset Pagination
GET /api/products?limit=20&offset=40

This is similar to SQL’s LIMIT and OFFSET.

Useful when clients need more control over how many records they retrieve.


3. Cursor Pagination
GET /api/products?cursor=cD00ODk=

Cursor pagination is ideal for large datasets because it doesn’t rely on offsets.

Benefits include:

  • Better performance on large tables
  • No duplicate records when new data is inserted
  • Consistent ordering

Cursor pagination is often the preferred choice for activity feeds, timelines, and continuously changing data.

Which Pagination Should You Choose?

There’s no single answer.

A good rule of thumb is:

Pagination Type Best Use Case
Page Number Dashboards, admin panels
Limit Offset Public APIs
Cursor Large datasets and feeds

Choose the strategy that matches how your clients consume data.

Don’t Forget Ordering

Pagination without ordering can produce inconsistent results.

Instead of:

Product.objects.all()

Use an explicit ordering:

Product.objects.order_by("-created_at")

This ensures records appear in a predictable order across pages.

Avoid Large Page Sizes

I’ve seen APIs configured with page sizes of 500 or even 1000 records.

While this reduces the number of requests, it also increases:

  • Response time
  • Memory usage
  • Network traffic
  • Database work

A page size between 20 and 100 is sufficient for most applications.

If clients need larger exports, consider generating downloadable files instead of increasing the page size.

Pagination Doesn’t Fix Slow Queries

Pagination reduces the amount of data returned, but it won’t fix an inefficient query.

If your query joins multiple tables, performs expensive filtering, or triggers the N+1 query problem, pagination alone won’t solve the issue.

Before optimizing pagination, make sure to:

  • Add appropriate indexes
  • Use select_related() or prefetch_related() where appropriate
  • Analyze the query using EXPLAIN ANALYZE
  • Fetch only the fields you actually need

Performance improvements usually come from combining these techniques.

Final Thoughts

Pagination is one of the simplest improvements you can make to a Django API, yet it’s often overlooked during the early stages of development.

Applications rarely stay small forever. As your database grows, endpoints that once felt fast can quickly become bottlenecks.

Adding pagination from the beginning helps your API remain responsive, scalable, and easier to maintain.

Good APIs don’t just return data—they return the right amount of data.

Bonus

Creating a Custom Pagination Class

Django REST Framework allows us to customize pagination by extending one of its built-in pagination classes.

Let’s create a reusable pagination class.

# core/pagination.py

from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response


class CustomPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"
    max_page_size = 100

    def get_paginated_response(self, data):
        return Response({
            "success": True,
            "message": "Products fetched successfully.",
            "pagination": {
                "current_page": self.page.number,
                "total_pages": self.page.paginator.num_pages,
                "page_size": self.get_page_size(self.request),
                "total_items": self.page.paginator.count,
                "has_next": self.page.has_next(),
                "has_previous": self.page.has_previous(),
            },
            "results": data,
        })

Instead of DRF’s default response:

{
    "count": 150,
    "next": "...",
    "previous": "...",
    "results": []
}

You’ll now receive:

{
    "success": true,
    "message": "Products fetched successfully.",
    "pagination": {
        "current_page": 1,
        "total_pages": 8,
        "page_size": 20,
        "total_items": 150,
        "has_next": true,
        "has_previous": false
    },
    "results": []
}

This format is often easier for frontend applications to consume.

Using Custom Pagination in a GenericView

Instead of making every API use the same pagination, DRF lets you assign pagination per view.

from rest_framework import generics

from .models import Product
from .serializers import ProductSerializer
from .pagination import CustomPagination


class ProductListAPIView(generics.ListAPIView):
    queryset = Product.objects.all()
    serializer_class = ProductSerializer
    pagination_class = CustomPagination

Using Custom Pagination with APIView

If you’re using APIView, pagination isn’t applied automatically. You’ll need to create an instance of your pagination class and paginate the queryset manually.

from rest_framework.views import APIView
from rest_framework.response import Response

from .models import Product
from .serializers import ProductSerializer
from .pagination import CustomPagination


class ProductListAPIView(APIView):
    pagination_class = CustomPagination

    def get(self, request):
        queryset = Product.objects.all().order_by("-id")

        paginator = self.pagination_class()
        page = paginator.paginate_queryset(queryset, request)

        serializer = ProductSerializer(page, many=True)

        return paginator.get_paginated_response(serializer.data)

Although it requires a few extra lines of code, this approach gives you complete control over how the queryset is built before pagination is applied.