Filtering a selector with filter_set¶
A list (or retrieve) selector returns a queryset; which fields are filterable,
with which lookups is a separate, declarative concern. SelectorSpec.filter_set
holds a django-filter FilterSet and
applies it to the selector's queryset, reading the values off
request.query_params.
selector() -> QuerySet
→ select_related / prefetch_related / annotations / extend_queryset
→ filter_set(data=request.query_params, queryset=qs).qs
→ (retrieve) .first()
→ output_serializer
filter_set is the fifth queryset-shaping field; it runs
last, after the eager-loading fields, so filtering composes with them and (for
retrieve) narrows the queryset before the framework materializes it with .first().
Why it lives on the spec¶
This library deliberately doesn't ship a filtering DSL — for an HTTP list endpoint,
DRF's filter_backends (DjangoFilterBackend) already do the job. filter_set
exists for the two cases filter_backends can't serve:
- Retrieve selectors. DRF's
RetrieveModelMixinnever callsfilter_queryset, so a detail selector that returns a filtered queryset — a "latest matching", a stats-style read scoped by?since=…— had no declarative hook and had to hand-roll the filtering inside the selector.filter_setnarrows the queryset before the framework.first()s it. - Transport-neutral declaration. A
FilterSetis fundamentally(data, queryset) -> .qs— a contract that doesn't care wheredatacomes from. The sameSelectorSpeccan drive an alternate transport (thedjangorestframework-mcp-serverbridge), where the values arrive in a tool-call args dict instead ofrequest.query_params. Declaring the filter on the spec means it's written once and consumed by every transport, instead of each one re-implementing it.
The library applies the FilterSet by duck typing — it imports nothing, so
filter_set adds no dependency. You only need django-filter installed to write
the FilterSet:
Define the pieces¶
A model:
# blog/models.py
from django.db import models
class Post(models.Model):
title = models.CharField(max_length=200)
published = models.BooleanField(default=False)
views = models.PositiveIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
A FilterSet describing the filterable shape:
# blog/filters.py
import django_filters
from blog.models import Post
class PostFilterSet(django_filters.FilterSet):
published = django_filters.BooleanFilter()
min_views = django_filters.NumberFilter(field_name="views", lookup_expr="gte")
created_after = django_filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
class Meta:
model = Post
fields = ["published", "min_views", "created_after"]
A selector that returns a raw queryset — no filtering inside it:
# blog/selectors.py
from django.db.models import QuerySet
from blog.models import Post
def list_posts(*, user) -> QuerySet[Post]:
"""Return every post the caller may see — scoping only, no filtering."""
return Post.objects.for_user(user) # your scoping manager
Wire it on the spec:
from rest_framework_services import SelectorKind, SelectorSpec, SelectorViewSet
from blog.filters import PostFilterSet
from blog.selectors import list_posts
from blog.serializers import PostSerializer
class PostViewSet(SelectorViewSet):
queryset = Post.objects.all()
action_specs = {
"list": SelectorSpec(
kind=SelectorKind.LIST,
selector=list_posts,
output_serializer=PostSerializer,
filter_set=PostFilterSet,
select_related=["author"], # composes with shaping
),
}
GET /posts/?published=true&min_views=100 narrows the queryset; omitting the
params is a no-op (a FilterSet with no matching data returns the queryset
unchanged).
On a retrieve selector¶
The same field works on a RETRIEVE spec, where it closes the gap DRF leaves —
the selector returns a queryset and filter_set narrows it before .first():
def latest_post(*, user) -> QuerySet[Post]:
return Post.objects.for_user(user).order_by("-created_at")
class LatestPostView(SelectorRetrieveView):
spec = SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=latest_post,
output_serializer=PostSerializer,
filter_set=PostFilterSet,
)
GET /latest-post/?published=true returns the most recent published post.
filter_set replaces DjangoFilterBackend on the list path¶
On the list path DRF's list() already runs filter_queryset() over the
view's filter_backends. DjangoFilterBackend does
filterset_class(query_params, queryset, request=request).qs — the same thing
filter_set does, down to passing the request when the FilterSet accepts one
(see The request inside a filter_set) — so
wiring both for one action would filter the queryset twice.
The rule: filter_set replaces DjangoFilterBackend. If you set filter_set,
don't also list DjangoFilterBackend in that view's filter_backends. The library
enforces it — a list selector spec that carries filter_set while the view also
wires DjangoFilterBackend raises ImproperlyConfigured at as_view() time:
class PostViewSet(SelectorViewSet):
filter_backends = [DjangoFilterBackend] # ← conflicts with filter_set below
action_specs = {
"list": SelectorSpec(
kind=SelectorKind.LIST,
selector=list_posts,
filter_set=PostFilterSet, # ← raises at as_view()
),
}
Retrieve has no such conflict: the selector retrieve path overrides get_object()
and never calls filter_queryset, so filter_set is the only filter applied.
Invalid filter input is a 400¶
Replacing DjangoFilterBackend means keeping its contract, and the part of that
contract that bites is validation. A FilterSet validates its bound form via
is_valid(); reading .qs without validating returns the unfiltered
queryset in django-filter's default non-strict mode. So a bad ?field= value — a
non-numeric min_views above, a ChoiceFilter value outside its choices —
would quietly answer 200 with every row instead of the 400 the backend gives.
filter_set validates, and raises the FilterSet's errors as a DRF
ValidationError:
The errors render in the usual {field: [message]} shape, because a Django form
ErrorDict is what DRF already knows how to render — which is how the core stays
free of any django-filter import.
This is enforced only when the object you hand to filter_set actually exposes
is_valid. A bare (data, queryset) -> .qs duck-typed stand-in that doesn't opt
into validation keeps its pass-through behaviour — it never had a form to
validate.
The request inside a filter_set¶
A django-filter FilterSet can reach into self.request — to scope by the
caller (self.request.user), to build a request-aware choice queryset
(ModelChoiceFilter(queryset=lambda request: …)), or in an __init__ / qs
override. DjangoFilterBackend fills self.request view-side; filter_set
fills it too. The dispatcher forwards the request into the FilterSet whenever its
constructor declares one (django-filter's __init__ does), so a request-scoped
FilterSet behaves the same on a spec as behind the backend, instead of seeing
self.request is None and raising AttributeError (a 500). A bare
(data, queryset) duck-typed stand-in that doesn't declare request is called
exactly as before — nothing is forced on it.
Forwarding is safe on every transport because the request is always present at
the point filtering runs — it's the same object extend_queryset already receives
— and its fidelity is well-defined:
| Transport | self.request |
.user / .query_params |
headers · session · META |
|---|---|---|---|
| HTTP view | live DRF request | real | real |
| MCP server | live DRF request (wraps the real Django request) | real | real |
| Pydantic-AI · AG-UI | synthetic off-HTTP request | faithful | best-effort (usually empty) |
So self.request.user scoping and ?param reads work identically everywhere; only
deep HTTP attributes (headers, session, COOKIES) degrade off-HTTP — and they
degrade there for every hook that reads the request (extend_queryset, context
providers), not just filters. Keep a FilterSet meant to run on more than one
transport to user / query_params; reach for headers or session only in a
FilterSet you know is HTTP-only.
Why forward at all, rather than keep filters request-free? Because the request is
already threaded to every other queryset-shaping hook, so withholding it from
filter_set alone was the inconsistency — it made a FilterSet that worked behind
DjangoFilterBackend fail once moved onto a spec. Forwarding closes that gap while
the signature gate preserves the transport-neutral (data, queryset) -> .qs
contract for stand-ins that want nothing to do with a request.
OpenAPI schema¶
With the OpenAPI integration enabled (enable_openapi() +
drf-spectacular), a spec-level filter_set documents the same query
parameters a view-level filterset_class + DjangoFilterBackend would — same
names, types, enums, ordering enum + description, style/explode, and required
flags. Moving a FilterSet off the view and onto the spec therefore leaves the
generated OpenAPI document unchanged, so client codegen and "schema must not
change" review gates keep passing.
The parameters are emitted on list operations, matching drf-spectacular's own
behaviour (it documents filter parameters only for list views). A retrieve
selector still filters at runtime — filter_set narrows the queryset before
.first() — but, like a view-level FilterSet on a detail route, contributes no
query parameters to the detail operation's schema. User @extend_schema(parameters=...)
overrides continue to win. The introspection needs the [filter] extra
(django-filter); without it, schema generation is unaffected.
When to use kwargs instead¶
filter_set earns its place only when the selector returns a queryset. If a
selector returns an .aggregate() dict or any other computed object, a FilterSet
can't apply to it — those ?since=… params are computation inputs, not queryset
filters. Reach for kwargs / get_selector_kwargs() there and let the selector
consume them:
def post_stats(*, request) -> dict[str, int]:
since = request.query_params.get("since")
qs = Post.objects.all()
if since:
qs = qs.filter(created_at__gte=since)
return qs.aggregate(total=Count("id"), views=Sum("views")) # not a QuerySet
class PostStatsView(SelectorRetrieveView):
spec = SelectorSpec(
kind=SelectorKind.RETRIEVE,
selector=post_stats, # returns a dict, so no filter_set
output_serializer=PostStatsSerializer,
)
Setting filter_set (or any shaping field) on a selector that returns a
non-queryset raises ImproperlyConfigured at request time — the loud failure is
the signal you've reached past the boundary.