Query parameters, django-filter, and FilterSet
coreintermediateFiltering 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.
Think of it as
A filter backend is a queryset-to-queryset function that runs after `get_queryset()` and before pagination, and that ordering is the whole design. `get_queryset()` decides what this caller is *allowed* to see; filter backends decide which slice of that they *asked* for. Keeping those separate is what makes filtering safe to expose: no query parameter can widen the set, because the permitted set was fixed before any backend ran. The step up from hand-rolled parsing to a `FilterSet` is about surface area, not elegance. Hand-parsing means you write the "is this parameter present", "is this value a valid date", "which lookup does this map to" logic yourself, once per parameter, and the failure mode of getting it wrong is a 500 rather than a 400. A `FilterSet` is a declaration: it names exactly which parameters exist, what each one maps to, and how each value is coerced — so an unknown parameter is ignored and a malformed one is a validation error, both for free.
What we're doing: Expose a small, deliberate filter surface on an orders endpoint, including one parameter the ORM cannot express declaratively.
- 2
- `ChoiceFilter` rejects a value outside the model's own choices with a 400 rather than passing it to the database and returning an empty list — a wrong value and a valid-but-empty result stop looking alike.
- 3–4
- `field_name` decouples the public parameter name from the column. `min_total` and `placed_after` read as API vocabulary; `total__gte` and `created_at__gt` leak the schema.
- 5–12
- `method=` is the escape hatch for anything declarative filters cannot say. `.distinct()` matters here — joining to a reverse relation can multiply rows.
- 9
- `Meta.fields` is the closed list. A parameter absent from it does not exist, which is what keeps the surface auditable.
- 20–21
- Ownership scoping stays in `get_queryset()`, above every filter. No query parameter can reach another customer's orders, whatever it asks for.
Why this works: The `FilterSet` turns "which parameters does this endpoint accept" from something you reconstruct by reading `get_queryset()` into a declaration you can read in one place — and gives type coercion, choice validation, and 400-level errors without writing any of them.
Passing a query parameter straight into `filter(**...)`
Wrong
Better
What you see: `?customer__email__icontains=@rival.com` filters across a relation nobody meant to expose, and `?nonsense=1` raises `FieldError: Cannot resolve keyword 'nonsense' into field` — a 500 for what should be a 400.
Why: Kwargs built from user input make every model field, and every field reachable through every relation, part of the API surface — including ones the serializer deliberately never returns. The information leak is real: a filter that narrows on `customer__email` lets a caller confirm an email address without ever seeing it in a response body. A declared `FilterSet` inverts the default: nothing is filterable until it is named.
- 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.
Three ways to declare the same filter, in increasing order of control
Together
Remember: 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.
See also: search and ordering filters · query validation and complexity controls · customizing generic view behavior

