Engineering

Pagination, Filtering and Search in Django REST Framework

On this page
  1. Turn on pagination for every list
  2. Pick the right pagination style
  3. Cap the page size
  4. Cursor pagination
  5. Filtering with django-filter
  6. Search
  7. Ordering
  8. Index what clients filter and sort on
  9. Putting it together
  10. Frequently asked questions
  11. More in this series

A list endpoint without pagination works until the table grows. Then a single request serializes ten thousand rows, the response takes seconds, and the client throws most of it away. Pagination, filtering and search keep list endpoints fast and useful, and Django REST Framework has all three built in.

Turn on pagination for every list#

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

Every generic list view now returns one page, with links to its neighbors:

json
{
  "count": 1342,
  "next": "https://api.example.com/books/?page=3",
  "previous": "https://api.example.com/books/?page=1",
  "results": [{"id": 41, "title": "Two Scoops of Django"}]
}

Pick the right pagination style#

StyleClient asks forGood forWatch out for
PageNumberPagination?page=3Small and medium tables, screens with page numbersA COUNT query on every request; deep pages get slow
LimitOffsetPagination?limit=20&offset=40Clients that need arbitrary windowsThe same COUNT; large offsets read and skip rows
CursorPagination?cursor=cD0yMDI1Large tables, feeds, infinite scrollNo total and no jumping to page N; needs a fixed ordering

Offset-based styles get slower the deeper a client goes, because the database still reads and discards every row before the offset. They can also show a row twice, or skip one, when rows are added while a client is paging. Cursor pagination avoids both: each page starts exactly where the previous one ended.

Cap the page size#

pagination.py
from rest_framework.pagination import PageNumberPagination


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

Letting clients choose a page size is fine, as long as max_page_size caps it. Without the cap, ?page_size=1000000 turns pagination off.

Cursor pagination#

pagination.py
from rest_framework.pagination import CursorPagination


class FeedPagination(CursorPagination):
    page_size = 50
    ordering = "-created_at"

The ordering field must never change once a row exists, and should be unique or nearly so, like a creation timestamp. Index it. The cursor in the URL is opaque on purpose: clients follow the next and previous links rather than building URLs.

Filtering with django-filter#

bash
pip install django-filter

Add "django_filters" to INSTALLED_APPS, then enable the filter backends:

settings.py
REST_FRAMEWORK = {
    # ...the pagination settings from above
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}

For exact matches, list the fields on the view:

views.py
class BookViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Book.objects.select_related("author")
    serializer_class = BookSerializer
    filterset_fields = ["author", "language"]

/books/?author=7&language=en now filters the list. For ranges and other lookups, write a FilterSet:

filters.py
import django_filters

from .models import Book


class BookFilter(django_filters.FilterSet):
    published_after = django_filters.DateFilter(field_name="published", lookup_expr="gte")
    published_before = django_filters.DateFilter(field_name="published", lookup_expr="lte")
    min_price = django_filters.NumberFilter(field_name="price", lookup_expr="gte")

    class Meta:
        model = Book
        fields = ["author", "language"]

Set filterset_class = BookFilter on the view. Invalid values, such as a malformed date, get a 400 response with an error message instead of silently matching nothing.

views.py
class BookViewSet(viewsets.ReadOnlyModelViewSet):
    # ...
    search_fields = ["title", "author__name", "=isbn"]

/books/?search=django matches rows where any listed field contains the term, ignoring case. A prefix changes the lookup: ^ for "starts with", = for an exact (case-insensitive) match, $ for a regular expression, and @ for PostgreSQL full-text search.

Ordering#

views.py
class BookViewSet(viewsets.ReadOnlyModelViewSet):
    # ...
    ordering_fields = ["published", "title", "price"]
    ordering = ["-published", "-id"]

?ordering=-price sorts by price, highest first. Always set ordering_fields: without it, clients may sort by any readable serializer field, including unindexed columns that make every request scan the table.

The default ordering matters even when nobody asks for a sort. Paging through an unordered queryset can return rows in a different order on each request, and Django warns about it with UnorderedObjectListWarning. Ending with a unique column, such as -id, makes the order deterministic.

Index what clients filter and sort on#

Every filter, search and ordering parameter becomes a WHERE or ORDER BY clause. Add indexes for the combinations clients actually use:

models.py
class Book(models.Model):
    # ...fields

    class Meta:
        indexes = [
            models.Index(fields=["author", "-published"]),
            models.Index(fields=["language", "-published"]),
        ]

Check the query plan with print(queryset.explain()), or run EXPLAIN ANALYZE on the SQL in psql, and look for sequential scans on large tables. On PostgreSQL, COUNT(*) on a large filtered table stays expensive even with good indexes. If clients don't need an exact total, cursor pagination drops the count entirely.

Putting it together#

views.py
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import filters, viewsets


class BookViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = Book.objects.select_related("author").prefetch_related("tags")
    serializer_class = BookSerializer
    pagination_class = StandardPagination
    filter_backends = [DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter]
    filterset_class = BookFilter
    search_fields = ["title", "author__name"]
    ordering_fields = ["published", "title", "price"]
    ordering = ["-published", "-id"]

A request such as /books/?author=7&published_after=2024-01-01&search=django&ordering=-price&page=2 combines all of them. Each parameter narrows the same queryset, so the database answers with one query for the page, plus the count and the tags prefetch. The select_related and prefetch_related calls are covered in the N+1 guide.

Frequently asked questions#

Which pagination class should I use in Django REST Framework?

PageNumberPagination for small and medium tables and screens that show page numbers. CursorPagination for large tables, activity feeds and infinite scroll. LimitOffsetPagination when clients need arbitrary windows of rows.

How do I avoid the COUNT query in paginated responses?

Use CursorPagination, which never counts. Page number and limit/offset pagination need the total to work out how many pages there are, so they always run the count.

Can I filter on related fields?

Yes. Use the double-underscore path in filterset_fields, search_fields or a FilterSet field's field_name, such as author__country. Add select_related() for the relation and an index on the column.

Why do I get an UnorderedObjectListWarning?

The paginated queryset has no ordering, so pages can overlap or skip rows between requests. Set ordering on the view, or add order_by() in get_queryset(), ending with a unique field.

More in this series#

This is part 3 of 5 in Django REST Framework in practice:

  1. DRF serializers: validation, nested writes and speed
  2. Authentication, permissions and JWT in DRF
  3. Pagination, filtering and search in DRF (this post)
  4. Fixing N+1 queries with select_related and prefetch_related
  5. Testing DRF APIs with pytest

Comments

No comments yet. Be the first.

Leave a comment

Only used to tell you about a reply. Never shown.

Plain text; line breaks are kept.

Let's build something

Hiring for a backend role, or have a project in mind? Send a message and I will reply by email.

At most 2 links.