---
title: "Pagination, Filtering and Search in Django REST Framework"
url: "https://nareshkumar.online/blog/drf-pagination-filtering-search"
author: "Naresh Kumar"
published: "2026-10-05"
updated: "2026-10-05"
category: "Engineering"
tags: ["API Design", "Django", "Django REST Framework", "Performance", "PostgreSQL", "Python"]
---

# Pagination, Filtering and Search in Django REST Framework

**In short:** Paginate every list endpoint. Use page numbers for small tables and cursors for large or fast-changing ones. Filter with django-filter, whitelist ordering fields, cap page sizes, and index the columns clients filter and sort on.

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`

```python
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

| Style | Client asks for | Good for | Watch out for |
| --- | --- | --- | --- |
| PageNumberPagination | ?page=3 | Small and medium tables, screens with page numbers | A COUNT query on every request; deep pages get slow |
| LimitOffsetPagination | ?limit=20&offset=40 | Clients that need arbitrary windows | The same COUNT; large offsets read and skip rows |
| CursorPagination | ?cursor=cD0yMDI1 | Large tables, feeds, infinite scroll | No 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`

```python
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`

```python
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`

```python
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`

```python
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`

```python
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.

## Search

`views.py`

```python
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.

> **Warning:** The default `icontains` search can't use an ordinary B-tree index, so it scans the table. That is fine for a few thousand rows. For larger tables, use PostgreSQL full-text search with a `SearchVectorField` and a GIN index, or a trigram index (`pg_trgm`) for fuzzy matching.

## Ordering

`views.py`

```python
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`

```python
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`

```python
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](https://nareshkumar.online/blog/django-n-plus-one-queries).

## 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](https://nareshkumar.online/blog/drf-serializers-validation-nested-writes)
2. [Authentication, permissions and JWT in DRF](https://nareshkumar.online/blog/drf-authentication-permissions-jwt)
3. Pagination, filtering and search in DRF (this post)
4. [Fixing N+1 queries with select\_related and prefetch\_related](https://nareshkumar.online/blog/django-n-plus-one-queries)
5. [Testing DRF APIs with pytest](https://nareshkumar.online/blog/testing-drf-apis-with-pytest)
