Django DRF #3: Filtering / Ordering / Pagination

8 min read

With auth/permissions in place from #2, this post covers the tools that refine list queries. With 100 posts you can return them all at once, but once the count reaches 100k or 100M, pagination and filtering become essential.

DRF has solid built-ins for all three.

  • Filtering — conditions like ?published=true&author=3
  • Ordering — sorting like ?ordering=-created_at
  • Pagination — how many at a time, where the next page is

filter_backends — the filtering pipeline in one place #

The list action of ListAPIView / ModelViewSet takes the queryset, runs it through filter_backends in order, then serializes the result. Three backends are standard.

settings.py
REST_FRAMEWORK = {
    "DEFAULT_FILTER_BACKENDS": [
        "django_filters.rest_framework.DjangoFilterBackend",
        "rest_framework.filters.SearchFilter",
        "rest_framework.filters.OrderingFilter",
    ],
}

Each fills a different slot.

BackendBehaviorExample query
DjangoFilterBackendPer-field equality/range filter?author=3&published=true
SearchFilterText search (icontains, etc.)?search=django
OrderingFilterSort?ordering=-created_at

django-filter — field filtering #

Install
uv add django-filter
settings.py
INSTALLED_APPS += ["django_filters"]

Simplest — filterset_fields #

blog/views.py
from django_filters.rest_framework import DjangoFilterBackend


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_fields = ["author", "published"]

That’s it. A query like ?author=3&published=true automatically becomes an equality filter.

Request
GET /api/posts/?author=3&published=true

FilterSet — richer expression #

When even the same field needs lookups like gte, lt, or in, use a FilterSet class.

blog/filters.py
import django_filters
from .models import Post


class PostFilter(django_filters.FilterSet):
    title = django_filters.CharFilter(lookup_expr="icontains")
    created_after = django_filters.DateFilter(
        field_name="created_at", lookup_expr="gte"
    )
    created_before = django_filters.DateFilter(
        field_name="created_at", lookup_expr="lte"
    )
    author_in = django_filters.BaseInFilter(field_name="author")
    has_comments = django_filters.BooleanFilter(method="filter_has_comments")

    class Meta:
        model = Post
        fields = ["author", "published"]

    def filter_has_comments(self, queryset, name, value):
        if value:
            return queryset.filter(comments__isnull=False).distinct()
        return queryset.filter(comments__isnull=True)
blog/views.py
from .filters import PostFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend]
    filterset_class = PostFilter

Query examples:

Various conditions
?title=django                       # title contains "django" (icontains)
?created_after=2026-01-01           # since that date
?created_before=2026-12-31          # before that date
?author_in=1,2,3                    # author IN (1, 2, 3)
?has_comments=true                  # only posts with comments

Method filter — arbitrary logic #

With method=..., an arbitrary function acts as the filter (has_comments above is the example). Useful for conditions that can’t be expressed as a simple lookup.

OrderingFilter — sorting #

blog/views.py
from rest_framework.filters import OrderingFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, OrderingFilter]
    filterset_class = PostFilter
    ordering_fields = ["created_at", "title"]
    ordering = ["-created_at"]   # default ordering

ordering_fields is the allowlist. Fields not listed are ignored — to keep clients from doing dangerous orderings like ?ordering=secret_field.

Queries
?ordering=-created_at        # descending
?ordering=title              # ascending
?ordering=-published,title   # multiple sort keys

ordering_fields = "__all__" is also possible, but ordering by an unindexed field is slow on large tables. An explicit allowlist is safer.

SearchFilter — text search #

Simple search across multiple fields.

blog/views.py
from rest_framework.filters import SearchFilter


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
    search_fields = ["title", "body", "author__username"]
Query
?search=django

Use a prefix on search_fields to specify the search mode.

PrefixMeaning
(none)icontains (substring, case-insensitive)
^istartswith (prefix match)
=iexact (exact match)
@full-text search (Postgres only)
$regex
search_fields example
search_fields = ["^title", "=email", "body"]

Real search needs a separate tool #

SearchFilter is for a fast start on small data. For real search:

  • PostgreSQL full-text search (SearchVector, SearchQuery)
  • Search engines like OpenSearch / Elasticsearch / Meilisearch

Combined with the index strategies from Advanced #3 Query Optimization, you survive on large data.

Pagination — three flavors #

Three paginators DRF provides as standard. Each has a different slot.

PageNumberPaginationLimitOffsetPaginationCursorPagination
Query?page=2?limit=20&offset=40?cursor=cD0xMjM=
Metacount, next, previouscount, next, previousnext, previous (no count)
Large tableSlows down (count + offset)Very slow (deeper offset)Fast (uses index)
Sort changePossiblePossibleFixed ordering required
Add/remove dataPage jitterPage jitterStable
Best fitGeneral UI paginationClient controls offset directlyInfinite scroll, large tables

PageNumberPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}
Request
GET /api/posts/?page=2
Response
{
  "count": 1234,
  "next": "http://localhost:8000/api/posts/?page=3",
  "previous": "http://localhost:8000/api/posts/?page=1",
  "results": [...]
}

The most familiar shape — pairs naturally with “1, 2, 3 … 62”-style pagination UI.

Custom PageNumberPagination #

blog/pagination.py
from rest_framework.pagination import PageNumberPagination


class StandardPagination(PageNumberPagination):
    page_size = 20
    page_size_query_param = "page_size"   # client adjustable
    max_page_size = 100                   # max per page

With page_size_query_param, clients can adjust page size like ?page_size=50 (pair with max_page_size to prevent abuse).

LimitOffsetPagination #

settings.py
REST_FRAMEWORK = {
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
}
Request
GET /api/posts/?limit=20&offset=40

A shape where you can jump directly with offset instead of page number. 1:1 with SQL’s LIMIT/OFFSET.

CursorPagination — the safe answer for large tables #

Both PageNumber and LimitOffset share the trap that deeper offsets get slower. SQL has to scan all the way through preceding rows to reach them.

🚫 Deep offset
SELECT * FROM blog_post ORDER BY created_at DESC LIMIT 20 OFFSET 100000;

This query reads 100,020 rows to return the last 20. It slows down linearly as pages get deeper, and if rows are added/removed in the meantime, the same post can appear twice or be skipped (page jitter).

CursorPagination jumps with an index.

✅ cursor
SELECT * FROM blog_post WHERE created_at < '2026-05-01 12:00:00' ORDER BY created_at DESC LIMIT 20;

It remembers the created_at of the last row seen and fetches “before that” for the next page. Constant time regardless of data size.

blog/pagination.py
from rest_framework.pagination import CursorPagination


class PostCursorPagination(CursorPagination):
    page_size = 20
    ordering = "-created_at"   # must be an indexed field
    cursor_query_param = "cursor"
blog/views.py
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination
Request
GET /api/posts/                          # first page
GET /api/posts/?cursor=cD0xMjM=          # next page

The response has no count (computing it would mean a full scan).

{
  "next": "http://.../?cursor=cD0xMjM=",
  "previous": null,
  "results": [...]
}

When to use what #

SituationRecommended
Small table (~tens of thousands), page UIPageNumberPagination
Client adjusts offset freelyLimitOffsetPagination
Large table (hundreds of thousands+), infinite scroll, feedCursorPagination
Twitter/Instagram-style timelineCursorPagination

Per-view paginator override #

To use a different paginator from the global one, set pagination_class.

Per ViewSet
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer
    pagination_class = PostCursorPagination


class CommentViewSet(viewsets.ModelViewSet):
    queryset = Comment.objects.all()
    serializer_class = CommentSerializer
    pagination_class = StandardPagination

To turn off pagination, use pagination_class = None.

get_queryset — per-user querysets #

The IsOwner from #2 doesn’t apply to list — list has no per-object check. To show only my posts, filter in get_queryset.

Mine only
class PostViewSet(viewsets.ModelViewSet):
    serializer_class = PostSerializer

    def get_queryset(self):
        qs = Post.objects.all()
        if self.action == "list":
            # List = published + mine
            if self.request.user.is_authenticated:
                return qs.filter(
                    Q(published=True) | Q(author=self.request.user)
                )
            return qs.filter(published=True)
        return qs   # retrieve/update use object-level permission separately

The key point: get_queryset() is the starting point of filtering/pagination. Every backend operates on top of this queryset.

Relationship with caching #

When the same query/page is called frequently, view-level caching from Advanced #4 Caching is effective.

Simple view cache
from django.utils.decorators import method_decorator
from django.views.decorators.cache import cache_page


class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.all()
    serializer_class = PostSerializer

    @method_decorator(cache_page(60))   # 60 seconds
    def list(self, request, *args, **kwargs):
        return super().list(request, *args, **kwargs)

Pagination/filter/sort produce different responses per query parametercache_page keys on URL + query string, so they separate naturally. But for responses that differ per authenticated user, you need vary_on_headers or a per-user key.

select_related / prefetch_related — tools that go together #

For pagination to be effective, ORM queries also have to avoid N+1. The patterns from Advanced #3 Query Optimization carry over.

Preload in get_queryset
def get_queryset(self):
    return (
        Post.objects.select_related("author")        # FK
        .prefetch_related("comments")                # reverse FK / M2M
        .all()
    )

The queryset class attribute on a ViewSet is built once at module import time — dynamic logic that has to be evaluated per request (the request user, etc.) must be resolved in get_queryset().

Response metadata — count / next / previous #

The paginator wraps the response in a single dict.

PageNumber response
{
  "count": 1234,
  "next": "http://...?page=3",
  "previous": "http://...?page=1",
  "results": [
    { "id": 1, ... },
    { "id": 2, ... }
  ]
}

This shape is effectively standard, so the frontend can handle the same pattern every time. The client follows next until it’s null.

Recap #

What this post nailed down:

  • filter_backends pipeline — three backends: DjangoFilter / Search / Ordering
  • django-filterfilterset_fields simple form, FilterSet class for gte/in/method expressions
  • OrderingFilterordering_fields allowlist, default ordering
  • SearchFiltersearch_fields and prefixes (^, =, @, $)
  • Real search separates out to PG full-text or OpenSearch
  • Where the three paginators fit:
    • PageNumberPagination — general UI
    • LimitOffsetPagination — client controls offset directly
    • CursorPagination — safe answer for large tables/infinite scroll
  • pagination_class for per-view override, None to turn off
  • Branch by user/action in get_queryset()
  • Pair with select_related / prefetch_related to avoid N+1
  • Combination with view-level caching

The view cache from Advanced #4 Caching and the index/JOIN patterns from Advanced #3 Query Optimization naturally accompany pagination — tools for handling large data always travel as a bundle.

The next post (#4 Async work with Celery) covers the standard tool for separating heavy work — sending emails, calling external APIs, heavy conversions — from the response flow: Celery.

X