skip to content

In Django REST Framework, why does a writable PrimaryKeyRelatedField need a queryset, and how do you scope it to the requesting user?

level: middleimportance: should knowfreq 36%

answer

  1. input becomes a lookup
  2. an assertion at construction
  3. override a method, read the context
  4. out-of-scope looks like missing

basics

~20 s

A writable PrimaryKeyRelatedField turns the client's pk into an object with queryset.get(pk=...), so it needs a queryset to search. To scope it, override get_queryset() and filter on self.context['request'].user; ids outside it fail as 'object does not exist'.

solid answer

~40 s

The `queryset` is where a writable related field looks up the client's value: `PrimaryKeyRelatedField.to_internal_value()` runs `self.get_queryset().get(pk=data)`. So `RelatedField.__init__` asserts you gave a `queryset`, overrode `get_queryset()`, or set `read_only=True` — and that you did not combine `queryset` with `read_only=True`. The queryset also defines what a client may reference: with `Address.objects.all()`, anyone can attach any customer's address to their order. Scope it by subclassing the field and overriding `get_queryset()` to filter on `self.context['request'].user`; generic views put the request in the context. An out-of-scope id then fails validation with `Invalid pk "12" - object does not exist.`, which also avoids confirming that the row exists. Permission classes will not catch this, because they check the object the view acts on, not ids referenced in the body.

code

python · 17 lines
python
from rest_framework import serializers

from shop.models import Address, Order


class CustomerAddressField(serializers.PrimaryKeyRelatedField):
    def get_queryset(self):
        request = self.context["request"]
        return Address.objects.filter(customer=request.user)


class OrderSerializer(serializers.ModelSerializer):
    shipping_address = CustomerAddressField()  # no queryset= needed

    class Meta:
        model = Order
        fields = ["id", "note", "shipping_address"]

go deeper

for a junior

Remember that a writable related field needs queryset=..., or read_only=True if it is output-only.

for a middle

Explain the lookup in to_internal_value(), the two construction assertions, and how ModelSerializer fills in the default manager and limit_choices_to.

for a senior

Spot unscoped related querysets as an authorization hole that permission classes miss, and fix them with a get_queryset() override that reads the request from context.

for a principal

Make tenant scoping systematic: shared field subclasses or a base serializer so no new endpoint can reference another tenant's rows by id.

## What the queryset is for In Django REST Framework (DRF), a **related field** such as `PrimaryKeyRelatedField` turns a model instance into a primitive value on output and a primitive value back into an instance on input. Output needs nothing but the instance. **Input needs a place to look**: `PrimaryKeyRelatedField.to_internal_value()` calls `self.get_queryset()` and then `.get(pk=data)`; `SlugRelatedField` and `HyperlinkedRelatedField` do the same with their own lookup. The default `get_queryset()` returns the `queryset` argument, calling `.all()` on it so it is re-evaluated on each use rather than cached from import time. ## The construction-time assertions `RelatedField.__init__` enforces the rule early, when the field is instantiated — for a field declared on a serializer class, that is at import time: 1. Unless `get_queryset()` is overridden, it asserts `queryset is not None or read_only`: *Relational field must provide a queryset argument, override get_queryset, or set read_only=True.* 2. It asserts you did not pass both a `queryset` and `read_only=True`: *Relational fields should not provide a queryset argument, when setting read_only=True.* `ModelSerializer` satisfies the rule for generated fields by passing the related model's **default manager** as the queryset and applying the model field's `limit_choices_to`. A relation to a many-to-many with a custom `through` model is generated read-only instead. ## The queryset is also an authorization boundary Whatever the queryset contains, a client can reference. Consider an order endpoint with `shipping_address = PrimaryKeyRelatedField(queryset=Address.objects.all())`: - A customer sends another customer's address id; validation passes; the order ships to — and exposes — someone else's address. - DRF **permission classes** do not help: `has_permission()` runs per request and `has_object_permission()` runs on the object the view retrieves with `get_object()`, not on ids referenced inside the payload. - An unscoped lookup also lets a client **probe** which ids exist, via the difference between success and "does not exist". ## Scoping it per request Subclass the field and override `get_queryset()`: - Read the request from `self.context["request"]`. Generic views and viewsets add `request`, `format` and `view` to the context through `get_serializer_context()`; a serializer built by hand must receive `context={"request": request}`. - Filter by the owning user or tenant. - Because `get_queryset()` is overridden, no `queryset` argument is needed and the first assertion is skipped. An id outside the scope now fails like a missing one — `Invalid pk "12" - object does not exist.` — so the response neither links the address nor confirms it exists. ## Scoping by more than the user The context can carry more than the request. A view can override `get_serializer_context()`, call `super()`, and add, say, the current tenant or the order being edited; the field's `get_queryset()` then reads `self.context["tenant"]`. Keep one rule: the field must fail closed. If the expected context key is missing — a serializer instantiated in a script or a test without it — raising a `KeyError` is better than falling back to an unscoped `Address.objects.all()`. ## Choosing where to scope | Approach | Where | Notes | |---|---|---| | `get_queryset()` override on a field subclass | the field | reusable; the idiomatic DRF answer | | `validate_shipping_address()` checking ownership | the serializer | works, but after a global lookup that can leak existence | | filtering `self.fields[...].queryset` in the serializer's `__init__` | the serializer | common in older code; easy to forget on a second serializer | | a permission class | the view | wrong layer; does not see referenced ids | ## Other details - **Empty strings.** `RelatedField.run_validation()` turns `''` into `None`, so an HTML form's blank select becomes a null, which fails unless the field has `allow_null=True`. - **Booleans.** A JSON `true` is rejected with `Incorrect type. Expected pk value, received bool.` rather than being treated as pk 1. - **Browsable API.** The HTML form lists the queryset's objects as choices, capped by the `HTML_SELECT_CUTOFF` setting (default 1000); scoping the queryset also keeps other users' rows out of that dropdown.

  • Why would a has_object_permission() check on the order view not stop a client from attaching someone else's address?
    `has_object_permission()` runs on the object `get_object()` returns, here the order being edited, and not at all on create. The address id travels in the body and is resolved by the serializer field, so only the field's queryset, or serializer validation, can refuse it.
  • How does ModelSerializer satisfy the queryset rule for the related fields it generates?
    It passes the related model's default manager as the queryset, filtered by the model field's `limit_choices_to` when one is set. That makes every row referenceable, which is why you override the field when rows belong to users or tenants.

saying these in an interview costs you the question

  • The queryset on a related field only feeds the browsable API's dropdown.
  • Permission classes already stop clients from referencing other users' objects by id.
  • Passing queryset together with read_only=True is harmless redundancy.
  • A related field without a queryset falls back to the model's default manager at runtime.