In Django REST Framework generic views, when should filtering live in get_queryset() rather than in a filter backend, and how do the two combine?
answer
- base set versus client choice
- filter_queryset(get_queryset())
- URL kwargs and fixed rules
- class attribute evaluated at import
basics
~10 sPut rules that always apply, or come from the URL, in get_queryset(); put optional, client-driven narrowing in filter backends. DRF runs filter_queryset(get_queryset()), so backends narrow the base set on list and every get_object() action.
solid answer
~40 s`get_queryset()` defines the **base set** a view may ever show: fixed rules such as 'only published, unexpired jobs' and values from the URL such as a company slug. Filter backends apply **optional, client-driven** narrowing from query parameters, reusable across views and described in the schema and browsable API. The two compose: `list()` and `get_object()` both call `self.filter_queryset(self.get_queryset())`, so backends only ever narrow what `get_queryset()` returned, and a base-set rule also makes excluded objects 404 on retrieve, update and delete. `get_queryset()` must return a `QuerySet`, not a list, so backends can keep chaining. Avoid filtering a class-level `queryset` with a runtime value like `timezone.now()`: DRF re-clones the queryset per request, but the value was computed once, at import.
code
python · 19 linesfrom django.utils import timezone
from django_filters.rest_framework import DjangoFilterBackend
from rest_framework import viewsets
from jobs.models import Job
from jobs.serializers import JobSerializer
class CompanyJobViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = JobSerializer
filter_backends = [DjangoFilterBackend]
filterset_fields = ["remote", "seniority"]
def get_queryset(self):
return Job.objects.filter(
company__slug=self.kwargs["company_slug"],
status="published",
expires_at__gt=timezone.now(),
).select_related("company")go deeper
Know that get_queryset() returns the view's starting QuerySet and that query parameters are read from request.query_params.
Explain filter_queryset(get_queryset()), the per-request .all() clone, and why a value like timezone.now() must be computed inside the method.
Show the side effects on write actions and object lookups, and choose between a view rule, a reusable backend or a FilterSet for validation and schema.
Set the team convention for what is an endpoint invariant versus a client filter, so that security scoping never depends on an optional parameter.
## Two layers with different jobs A Django REST Framework generic view (anything built on `GenericAPIView`, including viewsets) has two places to narrow data: - **`get_queryset()`**, a method on the view that returns the starting `QuerySet`. Its default returns the class attribute `queryset`, re-cloned with `.all()` on every request. - **Filter backends**, the classes in `filter_backends`, each with a `filter_queryset(request, queryset, view)` method, applied by `GenericAPIView.filter_queryset()`. | Aspect | `get_queryset()` | Filter backend | |---|---|---| | Who decides | the server | the client, via query parameters | | Optional? | no, it always applies | yes, absent parameters change nothing | | Typical inputs | constants, `self.kwargs`, `self.action` | `request.query_params` | | Reuse | per view (or a mixin) | one class across many views | | Schema and browsable API | not described | `get_schema_operation_parameters()`, `to_html()` | ## When get_queryset() is the right place - **Invariants of the endpoint.** A public job board never lists drafts or expired postings: `Job.objects.filter(status='published', expires_at__gt=timezone.now())`. - **Values from the URL path.** For `/companies/<slug>/jobs/`, filter by `self.kwargs['slug']`; the path is not an optional query parameter. - **Per-action shaping.** In a viewset, branch on `self.action` to add `select_related()` for list or loosen a rule for an admin-only action. - **One-off parameters** that exist on a single endpoint and do not justify a reusable class. The DRF docs show reading `self.request.query_params.get('username')` here. ## When a backend is the right place - The narrowing is a **client choice** that several endpoints share: `?remote=true`, `?search=`, `?ordering=`. - You want **validation and documentation** for free: `DjangoFilterBackend` builds a `FilterSet` from `filterset_fields` or `filterset_class`, and DRF's built-ins describe their parameters in the generated schema. - The same rule must apply across many views; add the backend to `DEFAULT_FILTER_BACKENDS` instead of copying `get_queryset()` logic. ## How they combine For every request that reads data, DRF does the same three steps: 1. call `get_queryset()` to get the base set; 2. pass it through each backend in `filter_backends`, in order; 3. paginate and serialize the result (`list()`), or look up one object by `lookup_field` and check object permissions (`get_object()`). Consequences worth saying in an interview: - a backend can never widen the base set, only narrow it; - base-set rules hit **write actions** too: `PATCH /jobs/<id>/` on an expired job returns 404, because `update()` finds the object through `get_object()`; branch on `self.action` if that is not intended; - `create()` uses neither layer. ## Pitfalls - **A runtime value in the class attribute.** `queryset = Job.objects.filter(expires_at__gt=timezone.now())` evaluates `now()` once when the module is imported. DRF's per-request `.all()` stops result caching, but the cutoff stays frozen, so expired jobs reappear as the process ages. Compute such values inside `get_queryset()`. - **Returning a list.** `list(qs)` or a sliced, evaluated result breaks backends that call `.filter()` or `.order_by()` next. - **Hand-parsing parameters without validation.** `filter(company_id=self.request.query_params['company'])` with `?company=abc` raises Django's `ValueError` ('expected a number') inside the view, which becomes a 500 rather than a 400; a `FilterSet` validates values through form fields first. - **Using a client-side filter for access control.** Anything the client can omit is not a restriction; scoping to the requesting user is a permission concern and belongs to the authorization design.
- Why does DRF call queryset.all() in the default get_queryset()?A class attribute `queryset` is shared by every request in the process. Calling `.all()` returns a fresh clone, so results cached on one request's QuerySet are not reused by the next. It does not recompute arguments baked into the filter, such as a `timezone.now()` evaluated at import.
- Your viewset hides expired jobs in get_queryset(), but staff must still be able to delete them. What do you change?Branch in `get_queryset()`: when `self.action == 'destroy'` (and the permission classes already restrict the action to staff), skip the expiry filter. `get_object()` then finds the expired job instead of raising 404. Keep the permission check separate; the queryset branch only widens what the action can look up.
saying these in an interview costs you the question
- Overriding get_queryset() disables the view's filter backends
- Filter backends run before get_queryset() and it narrows their output
- get_queryset() only affects list requests, not updates or deletes
- A class-level queryset filtered with timezone.now() re-evaluates now() per request
- get_queryset() may return a list as long as it is iterable