skip to content

In Django REST Framework, how do PrimaryKeyRelatedField, SlugRelatedField, StringRelatedField and HyperlinkedRelatedField differ in representing a related object?

level: juniorimportance: must knowfreq 60%

answer

  1. what goes out, what comes back in
  2. pk, slug, str(), URL
  3. one of the four is always read-only
  4. hyperlinks need something from the view

basics

~20 s

They differ in the value that stands for the related object: PrimaryKeyRelatedField uses its pk, SlugRelatedField a named unique field, HyperlinkedRelatedField its detail URL, and StringRelatedField str(obj), which is output-only because that field is always read-only.

solid answer

~40 s

All four subclass `RelatedField`; they differ in the value that stands for the related object. `PrimaryKeyRelatedField` renders `obj.pk` and, on input, runs `queryset.get(pk=value)`; it is what `ModelSerializer` generates for a foreign key by default. `SlugRelatedField(slug_field='sku', ...)` renders and looks up by that field, which should be `unique=True`. `HyperlinkedRelatedField(view_name='product-detail', ...)` renders an absolute URL built with `reverse()` and needs `request` in the serializer context; on input it resolves the URL back to an object. `StringRelatedField` renders `str(obj)` and is always read-only. The writable ones need a `queryset` (or an overridden `get_queryset()`) unless marked `read_only=True`, and `many=True` wraps any of them in a `ManyRelatedField` for to-many relations.

code

python · 20 lines
python
from rest_framework import serializers

from shop.models import LineItem, Product


class LineItemSerializer(serializers.ModelSerializer):
    # writable: clients send the product's pk
    product = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all())
    # read-only views of the same relation
    product_sku = serializers.SlugRelatedField(
        source="product", slug_field="sku", read_only=True
    )
    product_url = serializers.HyperlinkedRelatedField(
        source="product", view_name="product-detail", read_only=True
    )
    product_label = serializers.StringRelatedField(source="product")

    class Meta:
        model = LineItem
        fields = ["id", "product", "product_sku", "product_url", "product_label", "quantity"]

go deeper

for a junior

Recall the four output values: pk, slug, URL and str(). Know that ModelSerializer uses primary keys by default and that StringRelatedField is output-only.

for a middle

Explain what each field does on input: the queryset lookup, the error messages, and why writable fields need a queryset while HyperlinkedRelatedField also needs the request in context.

for a senior

Choose a representation per client: pks for internal apps, unique slugs for public APIs, hyperlinks when link-following is worth URLconf coupling. Catch non-unique slug columns before they turn into server errors.

for a principal

Treat the relation representation as part of the public contract: switching pks to slugs or URLs later breaks every client, so decide it once per API and document it.

## What a related field is In Django REST Framework (DRF), a **serializer** converts model instances to primitive data (output) and validates incoming primitive data back into Python values (input). When a model has a **relation** — a `ForeignKey`, `OneToOneField` or `ManyToManyField` — the serializer needs a rule for what the related object looks like on the wire. DRF's answer is a family of **related fields**, all subclasses of `rest_framework.relations.RelatedField`. Each one picks a different *handle* for the related object: its primary key, some unique column, a human-readable string, or a URL. The alternative to a related field is a **nested serializer**, which renders the whole related object as a JSON object. That is a separate choice; related fields keep the payload flat and are the default. ## The four fields side by side | Field | Output value | Accepts input? | Required arguments | |---|---|---|---| | `PrimaryKeyRelatedField` | `obj.pk` | yes, a pk | `queryset` unless `read_only=True` | | `SlugRelatedField` | `getattr(obj, slug_field)` | yes, a slug value | `slug_field`, plus `queryset` unless read-only | | `HyperlinkedRelatedField` | absolute detail URL | yes, a URL | `view_name`, plus `queryset` unless read-only | | `StringRelatedField` | `str(obj)` | **never** — always read-only | none | `ModelSerializer` uses `PrimaryKeyRelatedField` for relations by default (its `serializer_related_field` attribute). `HyperlinkedModelSerializer` swaps that for `HyperlinkedRelatedField` and derives `view_name` as `'<model_name>-detail'`, the name a DRF router gives a detail route. ## How each one reads input On a write, each writable field turns the client's primitive value back into a model instance in its `to_internal_value()`: 1. **`PrimaryKeyRelatedField`** calls `queryset.get(pk=value)`. A missing row fails with `Invalid pk "42" - object does not exist.`; a wrong type (including a JSON boolean) fails with `Incorrect type. Expected pk value, received bool.` 2. **`SlugRelatedField`** calls `queryset.get(**{slug_field: value})`. A missing row fails with `Object with sku=ABC-1 does not exist.` 3. **`HyperlinkedRelatedField`** strips the scheme and host, `resolve()`s the path against the URLconf, checks the matched view name equals `view_name`, then looks the object up by `lookup_field` (default `'pk'`) using the URL kwarg named by `lookup_url_kwarg`. 4. **`StringRelatedField`** has no input path at all: its constructor forces `read_only=True`. The resolved **model instance** — not the raw value — is what lands in `validated_data`. ## Configuration traps worth knowing - **Missing `queryset`.** A writable related field with no `queryset` and no `get_queryset()` override fails an assertion when it is constructed: `Relational field must provide a queryset argument, override get_queryset, or set read_only=True.` Passing both `queryset` and `read_only=True` fails a second assertion. - **Non-unique slug.** `SlugRelatedField` catches only `ObjectDoesNotExist`, `TypeError` and `ValueError`. If two rows share the slug, `MultipleObjectsReturned` escapes and the request becomes a server error, which is why the docs say to use a field with `unique=True`. - **No request in context.** `HyperlinkedRelatedField.to_representation()` asserts that `'request'` is in `serializer.context`. DRF's generic views add it through `get_serializer_context()`; a serializer built by hand in a function view, a management command or a test must pass `context={'request': request}`. - **URL coupling.** A hyperlinked field ties the payload to the URLconf; renaming a route breaks rendering with an `ImproperlyConfigured` error about the view name. ## What each one costs to render - `PrimaryKeyRelatedField`, and `HyperlinkedRelatedField` with the default `lookup_field='pk'`, use a **pk-only shortcut**: they read the stored foreign-key value (`product_id`) from the row being serialized and never load the related object. - `SlugRelatedField`, `StringRelatedField` and a hyperlink with any other `lookup_field` need the **related object itself**, so each rendered row touches the relation. On a list endpoint that is one extra query per row unless the view prefetches it — the fix belongs to the rendering-cost topic, but the choice of field decides whether the cost exists at all. ## To-many relations Passing `many=True` to any related field makes DRF build a `ManyRelatedField` wrapping it, so a `ManyToManyField` or reverse foreign key renders as a list of pks, slugs, URLs or strings. `ManyRelatedField` itself is private API; you never instantiate it directly. ## Choosing one - Internal clients and mobile apps: **primary keys** — cheap, unambiguous, and the default. - Public or human-facing APIs where ids are meaningless: a **slug** on a unique, stable column. - APIs that want clients to follow links: **hyperlinks**, accepting the URLconf coupling. - Display-only labels in a response: **`StringRelatedField`**, never for input. The same serializer may mix them by pointing several fields at one relation with `source=`, as the code example shows.

  • What happens if a serializer with a HyperlinkedRelatedField is rendered without the request in its context?
    `to_representation()` raises an `AssertionError` telling you to add `context={'request': request}`. Generic views and viewsets pass it automatically through `get_serializer_context()`, so the failure shows up when a serializer is built by hand, in a function view, a Celery task or a test.
  • What does SlugRelatedField do when two rows share the submitted slug value?
    Its `to_internal_value()` calls `queryset.get()` and catches only `ObjectDoesNotExist`, `TypeError` and `ValueError`. `MultipleObjectsReturned` escapes as an unhandled exception, so the client gets a server error instead of a 400. Point `slug_field` at a column with `unique=True`, or narrow the queryset until the value is unique.

Referring to a colleague: their employee number (pk), their unique username (slug), the link to their profile page (hyperlink), or just saying their name aloud (str). The first three let HR find the exact person; a spoken name identifies nobody reliably, so it is only ever used for display.

saying these in an interview costs you the question

  • StringRelatedField can be written to by sending the object's display name.
  • A ModelSerializer renders foreign keys as nested objects unless told otherwise.
  • SlugRelatedField is fine on a non-unique column because it takes the first match.
  • HyperlinkedRelatedField renders the same whether or not the request is in context.
  • A writable PrimaryKeyRelatedField works without any queryset.