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
- 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.
Command
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.