Filter concepts by levelShowing all levels.

Django · DRF Filtering

DRF Filtering, Search, and Ordering

django-filter and FilterSet, SearchFilter and OrderingFilter, custom backends, and query cost controls

  • Backends run after get_queryset()so a filter can only ever narrow what authorization already permitted
  • A FilterSet is a closed listnothing is filterable until it is declared — never build filter(**query_params)
  • Unknown parameters are ignored, not rejected?statuss=paid returns the full list with a 200
  • Unprefixed search_fields means icontainsLIKE '%term%' — no index can serve it, so it stops scaling
  • ordering_fields unset allows any serializer fieldthe documented data-leak default — always name the list

1.Query parameters, django-filter, and FilterSet

Filtering narrows a list endpoint from the URL: `GET /orders/?status=paid&created_after=2026-01-01`. DRF runs this through *filter backends* — classes listed in `filter_backends` that each get a chance to transform the queryset before pagination. You can do it by hand by reading `self.request.query_params` inside `get_queryset()`, and for one or two parameters that is perfectly reasonable. Beyond that, `django-filter` supplies `DjangoFilterBackend`: declare `filterset_fields = ["status", "customer"]` for plain equality matching, or write a `FilterSet` class when a parameter needs a lookup other than exact — a range, a date comparison, a name that differs from the model field.

Key features

  • Filter backends run in `filter_queryset()`, after `get_queryset()` and before pagination — so a filter can only ever narrow what the caller was already permitted to see
  • `DjangoFilterBackend` lives in `django_filters.rest_framework`, not in DRF itself; `django_filters` goes in `INSTALLED_APPS`
  • `filterset_fields = ["status", "customer"]` gives exact-match filtering on those fields and nothing else — the fast path when that is all you need
  • `filterset_fields` also accepts a dict for lookups: `{"created_at": ["gte", "lte"], "status": ["exact"]}` generates `created_at__gte` and `created_at__lte` parameters
  • A `FilterSet` class is the full form — each declared filter names its `field_name`, its `lookup_expr`, and optionally a `method` for anything the ORM cannot express declaratively
  • `DEFAULT_FILTER_BACKENDS` is empty by default, so nothing filters until you set it globally or per view

Three ways to declare the same filter, in increasing order of control

Three ways to declare the same filter, in increasing order of control
DeclarationGeneratesReach for it when
`get_queryset()` by handwhatever you writeone or two parameters, no coercion worth naming
`filterset_fields = ["status"]``?status=paid`exact matches on model fields
`filterset_fields = {"total": ["gte", "lte"]}``?total__gte=100&total__lte=500`ranges without writing a class
`filterset_class = OrderFilter`whatever the class declaresrenamed parameters, custom methods, cross-field logic
From a raw query string to a narrowed queryset

1 · get_queryset() sets the ceiling

Authorization decides the widest set this caller may ever see. Nothing a filter backend does can widen it.

2 · The FilterSet declares the parameters

Each filter names the model field, the lookup, and the value type. Anything not declared here does not exist as a parameter.

3 · The backend validates and applies

Values are coerced by the declared filter type. A bad date is a 400 with a field-level message, never a 500 from the database driver.

4 · Pagination runs last

The page is cut from the already-filtered queryset, so counts and next-page links describe the filtered set, not the whole table.

  1. 1 · get_queryset() sets the ceiling — Authorization decides the widest set this caller may ever see. Nothing a filter backend does can widen it.
  2. 2 · The FilterSet declares the parameters — Each filter names the model field, the lookup, and the value type. Anything not declared here does not exist as a parameter.
  3. 3 · The backend validates and applies — Values are coerced by the declared filter type. A bad date is a 400 with a field-level message, never a 500 from the database driver.
  4. 4 · Pagination runs last — The page is cut from the already-filtered queryset, so counts and next-page links describe the filtered set, not the whole table.

Command

python
filter_backends = [DjangoFilterBackend]
filterset_class = OrderFilter     # or: filterset_fields = ["status", "customer"]

Filter backends run after `get_queryset()` and before pagination, so they can only narrow what authorization already permitted — never widen it. Use `filterset_fields` for exact matches, its dict form for ranges, and a `FilterSet` class when a parameter needs its own name, lookup, or method. Never build `filter(**query_params)`: it exposes every field and every relation, and turns a typo into a 500.

2.SearchFilter, OrderingFilter, and custom backends

DRF ships two more backends beyond `DjangoFilterBackend`. `SearchFilter` reads a single `?search=` parameter and matches it against the fields you list in `search_fields`, defaulting to a case-insensitive "contains" match, with prefix characters selecting a different lookup. `OrderingFilter` reads `?ordering=` and sorts by the fields listed in `ordering_fields`, with a leading `-` for descending. Both are opt-in per view. A custom backend is any class with a `filter_queryset(self, request, queryset, view)` method returning a queryset — the same contract the shipped ones implement, which is how a project-wide rule (tenant scoping, soft-delete hiding) can be applied everywhere at once.

Key features

  • `SearchFilter` reads `?search=` by default; the parameter name comes from the `SEARCH_PARAM` setting
  • A `search_fields` entry with no prefix uses `icontains`; prefixes change it — `^` is `istartswith`, `=` is `iexact`, `@` is PostgreSQL full-text `search`, `$` is `iregex`
  • Multiple `search_fields` are OR-ed together for a single term, and multiple space-separated terms are AND-ed across the whole field set
  • `OrderingFilter` reads `?ordering=` (settable via `ORDERING_PARAM`); a leading `-` reverses, commas separate multiple keys, and `ordering = ["-created_at"]` sets the default when the client sends nothing
  • With `ordering_fields` unset, DRF allows ordering on any field readable on the serializer — the docs warn this can leak data such as a password hash; `"__all__"` is the explicit opt-in
  • A custom backend is one method — `filter_queryset(self, request, queryset, view)` — and putting it in `DEFAULT_FILTER_BACKENDS` applies it to every list endpoint in the project

`search_fields` prefixes

`search_fields` prefixes
PrefixLookupExample entryMatches
(none)`icontains``"title"`anywhere in the value — cannot use an index
`^``istartswith``"^sku"`the start of the value — an index can serve this
`=``iexact``"=email"`the whole value, case-insensitively
`@``search``"@body"`PostgreSQL full-text search only
`$``iregex``"$reference"`a regular expression — never expose to untrusted input
Two shipped backends, and what each one costs you

SearchFilter · ?search=

search_fields = ["^sku", "name"]

the closed list of what is searched

No prefix means icontains

LIKE '%term%' — a full scan, no index can help

^ means istartswith

an index can serve this one

Past ~1M rows, switch

PostgreSQL full-text or a real search engine

OrderingFilter · ?ordering=

ordering_fields = ["price"]

the closed list of what may be sorted on

Unset = any serializer field

the documented data-leak default — always name the list

-price sorts descending

comma-separate for multiple keys

ordering = ["-created_at"]

the default when the client sends nothing — also what keeps pages stable

  • GET /products/?search=blue&ordering=-price
  • SearchFilter · ?search= — one parameter, many fields, OR-ed together
    • search_fields = ["^sku", "name"] — the closed list of what is searched
    • No prefix means icontains — LIKE '%term%' — a full scan, no index can help
    • ^ means istartswith — an index can serve this one
    • Past ~1M rows, switch — PostgreSQL full-text or a real search engine
  • OrderingFilter · ?ordering= — sorts what is left, before pagination cuts the page
    • ordering_fields = ["price"] — the closed list of what may be sorted on
    • Unset = any serializer field — the documented data-leak default — always name the list
    • -price sorts descending — comma-separate for multiple keys
    • ordering = ["-created_at"] — the default when the client sends nothing — also what keeps pages stable

Command

python
filter_backends = [SearchFilter, OrderingFilter]
search_fields = ["^sku", "name"]
ordering_fields = ["name", "price"]   # never leave this unset

`SearchFilter` is one `?search=` parameter across `search_fields`; no prefix means `icontains`, which cannot use an index, so it stops scaling around a million rows. `OrderingFilter` reads `?ordering=` — and always declare `ordering_fields`, because the default allows any serializer-readable field and can leak one. Set a default `ordering` too, or paginated pages will repeat and skip rows. A custom backend is one method, and in `DEFAULT_FILTER_BACKENDS` it applies project-wide.

3.Query validation, complexity controls, and expensive filters

Every filter parameter you expose is a query a stranger gets to write. Three defences keep that manageable. **Validate**: reject a malformed or unknown parameter with a 400 instead of ignoring it or letting it reach the database as a 500. **Bound**: cap page size, cap how far back a date range may reach, and require the parameters that make a query cheap. **Refuse**: some filters are expensive no matter how they are written — an unbounded `icontains` across a joined table, a regex filter, an unindexed sort on a huge table — and the right answer is not to expose them, or to expose them only behind a narrower scope.

Key features

  • An unrecognised query parameter is silently ignored by both DRF and django-filter — `?statuss=paid` returns everything, with a 200
  • A `FilterSet` validates declared parameters and returns 400 with per-field messages; parameters outside it are still ignored unless you check the names yourself
  • Deep pagination is the common expensive case: `OFFSET 20000` makes the database produce and discard 20,000 rows before the page it returns
  • Ordering on an unindexed column forces a sort of the whole filtered set before pagination can take a slice
  • A required filter is expressed by overriding the FilterSet's `is_valid()`/`clean()` or by checking in `filter_queryset()` and raising `ValidationError` — a 400, not an empty list
  • PostgreSQL's `statement_timeout` is the backstop: it caps what any single query can cost, whatever the application layer failed to prevent

The expensive shapes, and what to do about each

The expensive shapes, and what to do about each
ShapeWhy it is expensiveControl
`?search=` with unprefixed fields`LIKE '%x%'` — no index is usableprefix with `^`, or move to full-text search
`?page=900``OFFSET` produces and discards every earlier rowcursor pagination, or cap `max_page_size` and total depth
`?ordering=` on an unindexed columnsorts the whole filtered setrestrict `ordering_fields` to indexed columns
unbounded date rangescans years to return a pagerequire a range, and cap its width
regex filter (`$` prefix)unindexable, and can backtrack catastrophicallydo not expose it to untrusted callers
filter across a many relationrow multiplication, then `DISTINCT`use `Exists()` rather than a join, or precompute a flag
Three gates between a query string and the database
noyesnoyesnoyesstilltoo slow

Incoming query string

?search=…&ordering=…&page=900

Gate 1 · are the parameter names known?

unknown names are ignored by default — check them yourself

Gate 2 · do the values validate?

the FilterSet coerces types and checks choices

Gate 3 · is the resulting query affordable?

range width, page depth, sort column, join fan-out

400 · unknown parameter

names the parameter, so a typo is found on the first call

400 · invalid value

per-field message from the declared filter type

400 · range too wide / depth too deep

refuse the shape, do not just run it slowly

Query runs

bounded, indexed, and paginated

statement_timeout

the database-side backstop for whatever slipped through

  • Incoming query string — ?search=…&ordering=…&page=900
    • leads to Gate 1 · are the parameter names known?
  • Gate 1 · are the parameter names known? — unknown names are ignored by default — check them yourself
    • on error, leads to 400 · unknown parameter (no)
    • leads to Gate 2 · do the values validate? (yes)
  • Gate 2 · do the values validate? — the FilterSet coerces types and checks choices
    • on error, leads to 400 · invalid value (no)
    • leads to Gate 3 · is the resulting query affordable? (yes)
  • Gate 3 · is the resulting query affordable? — range width, page depth, sort column, join fan-out
    • on error, leads to 400 · range too wide / depth too deep (no)
    • leads to Query runs (yes)
  • 400 · unknown parameter — names the parameter, so a typo is found on the first call
  • 400 · invalid value — per-field message from the declared filter type
  • 400 · range too wide / depth too deep — refuse the shape, do not just run it slowly
  • Query runs — bounded, indexed, and paginated
    • leads to statement_timeout (still too slow)
  • statement_timeout — the database-side backstop for whatever slipped through

Command

python
class StrictFilterSet(FilterSet):
    def is_valid(self):
        unknown = set(self.data) - set(self.filters) - {"page", "page_size", "ordering"}
        if unknown:
            raise ValidationError({"detail": f"Unknown parameters: {sorted(unknown)}"})
        return super().is_valid()

Every exposed filter is a query a stranger writes, and the threat is cost, not injection. Reject unknown parameter names — DRF ignores them, so a typo returns a full unfiltered list with a 200. Validate values through declared filter types so a bad one is a 400, not an empty result or a 500. Bound the expensive shapes: require a date range and cap its width, restrict `ordering_fields` to indexed columns, and use cursor pagination so depth does not cost anything. Keep `statement_timeout` as the backstop.

Remember

  • get_queryset() sets the ceiling and filter backends narrow it — anything that returns a fresh Model.objects.all() from the filtering path discards the authorization decision.
  • filterset_fields for exact matches, its dict form for ranges, a FilterSet class when a parameter needs its own name, lookup, or method.
  • SearchFilter prefixes: ^ istartswith, = iexact, @ full-text (PostgreSQL), $ iregex — and no prefix at all means the unindexable icontains.
  • The threat model for filtering is query cost, not injection: bound date ranges, restrict ordering to indexed columns, and keep statement_timeout as the backstop.

Pro tip

  • Reject unknown query parameter names explicitly — an ignored filter and a filter that matched everything look identical from the client side.
  • A cross-cutting rule (tenant scoping, soft-delete hiding) written once as a filter backend in DEFAULT_FILTER_BACKENDS applies to every list endpoint, including ones added later.

Next up

  • DRF Pagination
  • DRF Throttling and Rate Limiting
  • API Design

Sources

  • DRF Filtering — django-rest-framework.org/api-guide/filtering/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.