Django DRF #3: Filtering / Ordering / Pagination
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.
REST_FRAMEWORK = {
"DEFAULT_FILTER_BACKENDS": [
"django_filters.rest_framework.DjangoFilterBackend",
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
}Each fills a different slot.
| Backend | Behavior | Example query |
|---|---|---|
DjangoFilterBackend | Per-field equality/range filter | ?author=3&published=true |
SearchFilter | Text search (icontains, etc.) | ?search=django |
OrderingFilter | Sort | ?ordering=-created_at |
django-filter — field filtering
#
uv add django-filterINSTALLED_APPS += ["django_filters"]Simplest — filterset_fields
#
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.
GET /api/posts/?author=3&published=trueFilterSet — richer expression
#
When even the same field needs lookups like gte, lt, or in, use a FilterSet class.
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)from .filters import PostFilter
class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
filter_backends = [DjangoFilterBackend]
filterset_class = PostFilterQuery examples:
?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 commentsMethod 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
#
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 orderingordering_fields is the allowlist. Fields not listed are ignored — to keep clients from doing dangerous orderings like ?ordering=secret_field.
?ordering=-created_at # descending
?ordering=title # ascending
?ordering=-published,title # multiple sort keysordering_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.
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"]?search=djangoUse a prefix on search_fields to specify the search mode.
| Prefix | Meaning |
|---|---|
| (none) | icontains (substring, case-insensitive) |
^ | istartswith (prefix match) |
= | iexact (exact match) |
@ | full-text search (Postgres only) |
$ | regex |
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.
| PageNumberPagination | LimitOffsetPagination | CursorPagination | |
|---|---|---|---|
| Query | ?page=2 | ?limit=20&offset=40 | ?cursor=cD0xMjM= |
| Meta | count, next, previous | count, next, previous | next, previous (no count) |
| Large table | Slows down (count + offset) | Very slow (deeper offset) | Fast (uses index) |
| Sort change | Possible | Possible | Fixed ordering required |
| Add/remove data | Page jitter | Page jitter | Stable |
| Best fit | General UI pagination | Client controls offset directly | Infinite scroll, large tables |
PageNumberPagination #
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"PAGE_SIZE": 20,
}GET /api/posts/?page=2{
"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 #
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 pageWith page_size_query_param, clients can adjust page size like ?page_size=50 (pair with max_page_size to prevent abuse).
LimitOffsetPagination #
REST_FRAMEWORK = {
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.LimitOffsetPagination",
}GET /api/posts/?limit=20&offset=40A 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.
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.
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.
from rest_framework.pagination import CursorPagination
class PostCursorPagination(CursorPagination):
page_size = 20
ordering = "-created_at" # must be an indexed field
cursor_query_param = "cursor"class PostViewSet(viewsets.ModelViewSet):
queryset = Post.objects.all()
serializer_class = PostSerializer
pagination_class = PostCursorPaginationGET /api/posts/ # first page
GET /api/posts/?cursor=cD0xMjM= # next pageThe response has no count (computing it would mean a full scan).
{
"next": "http://.../?cursor=cD0xMjM=",
"previous": null,
"results": [...]
}When to use what #
| Situation | Recommended |
|---|---|
| Small table (~tens of thousands), page UI | PageNumberPagination |
| Client adjusts offset freely | LimitOffsetPagination |
| Large table (hundreds of thousands+), infinite scroll, feed | CursorPagination |
| Twitter/Instagram-style timeline | CursorPagination |
Per-view paginator override #
To use a different paginator from the global one, set pagination_class.
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 = StandardPaginationTo 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.
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 separatelyThe 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.
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 parameter — cache_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.
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.
{
"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_backendspipeline — three backends: DjangoFilter / Search / Orderingdjango-filter—filterset_fieldssimple form,FilterSetclass forgte/in/methodexpressionsOrderingFilter—ordering_fieldsallowlist, defaultorderingSearchFilter—search_fieldsand prefixes (^,=,@,$)- Real search separates out to PG full-text or OpenSearch
- Where the three paginators fit:
PageNumberPagination— general UILimitOffsetPagination— client controls offset directlyCursorPagination— safe answer for large tables/infinite scroll
pagination_classfor per-view override,Noneto turn off- Branch by user/action in
get_queryset() - Pair with
select_related/prefetch_relatedto 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.