Filter concepts by levelShowing all levels.

Django · Section 45

Django REST Framework — Must Be Strong for API Roles

Level
advanced
Read
30 min
Concepts
3

DRF layers a parallel set of building blocks over Django's own request/view/model shapes — Serializer mirrors Form, ModelSerializer mirrors ModelForm (auto-inferring field type and validators from the model, the same way ModelForm does), and Views/ViewSets/Routers mirror Django's own view and URL layer. Validation runs in a defined order — each field's own validators, then validate_<field_name>() for single-field custom logic, then validate(self, attrs), the only stage seeing every field together and therefore the only correct place for a genuinely cross-field rule. create()/update() are the two methods serializer.save() actually calls; ModelSerializer's defaults handle the straightforward case, but anything needing save-time transformation (hashing a password, a nested write) requires an explicit override — DRF raises NotImplementedError for a writable nested field rather than guessing at its semantics. read_only/write_only control a field's direction independently of whether it exists on the model at all; context (typically {"request": request} from a view's get_serializer_context()) is the clean, explicit channel for request-scoped data inside serializer logic; SerializerMethodField and a dynamic __init__(fields=...) pattern round out the toolkit for computed fields and multi-shape serializers.

What is true here

  1. DRF mirrors Django's own shapes: Serializer:Form, ModelSerializer:ModelForm, APIView/ViewSet:View.
  2. Validation runs field validators → validate_<field>() → validate() — only validate() sees every field together, so cross-field rules belong there.
  3. create()/update() are what serializer.save() actually calls — override either for save-time logic a straight field copy can't express.
  4. A nested serializer is read-only by default; DRF raises NotImplementedError rather than guessing at nested-write semantics.
  5. context is the explicit channel for request-scoped data inside serializer logic — always accessed via .get(), never an assumed key.

What you will be able to do

  • Choose between a plain Serializer and ModelSerializer, and keep the API's exposed field set a deliberate decision
  • Place validation logic at the correct stage — single-field vs cross-field
  • Override create()/update() correctly for save-time transformations DRF's defaults can't express
  • Use nested serializers, read/write-only fields, context, and dynamic field sets appropriately

DRF architecture, Serializer, and ModelSerializer

How DRF mirrors Django's own Form/ModelForm shape, and field inference.

DRF architecture, Serializers, ModelSerializer, and fields

coreintermediate

DRF layers a parallel set of building blocks over Django's own request/view/model — Serializers (translate between Python/model objects and JSON, plus validation), Views/ViewSets (the request-handling layer), and Routers (auto-generate URLs from a ViewSet) — each mirroring a Django concept while adding API-specific behavior. A Serializer is DRF's equivalent of a Form: fields declared explicitly, is_valid() runs validation, .data produces JSON-ready output. ModelSerializer is DRF's equivalent of ModelForm — it auto-generates fields from a model's own fields, inferring type and validators (e.g. a CharField's max_length becomes an automatic length validator) the same way ModelForm does, dramatically cutting boilerplate for the common "expose this model over an API" case.

Think of it as

DRF is deliberately built as a set of Django-shaped analogues rather than an unrelated API framework bolted on — Serializer:Form :: ModelSerializer:ModelForm :: APIView:View is the actual, intended mental mapping, because DRF's own design goal was to feel idiomatic to someone who already knows Django, not to introduce an entirely separate paradigm. This is precisely why ModelSerializer auto-generating fields from a model works so similarly to ModelForm: both walk the model's field definitions and infer the corresponding serializer/form field type plus its validators (max_length, choices, unique, blank/null) automatically, since a model field already carries all the information needed to build a reasonable default. The layered architecture (Serializer for shape/validation, View/ViewSet for request handling, Router for URL generation) exists so each concern can be swapped independently — a ModelViewSet with a plain APIView-style serializer, or a plain APIView with a ModelSerializer, are both legitimate combinations, because DRF doesn't force the layers to be used only in lockstep.

python
class MySerializer(serializers.ModelSerializer):
    class Meta:
        model = MyModel
        fields = ["field1", "field2"]

What we're doing: A ModelSerializer exposing an explicit field list, with one field overridden to change its inferred behavior.

articles/serializers.pypython
class ArticleSerializer(serializers.ModelSerializer):
    author_name = serializers.CharField(source="author.get_full_name", read_only=True)

    class Meta:
        model = Article
        fields = ["id", "title", "author_name", "status", "published_at"]
2
author_name is not a real model field — source="author.get_full_name" tells DRF to traverse the relationship and call a method, a common ModelSerializer pattern for a computed/derived output field.
6
fields lists author_name (the custom field), not author — an explicit field list can mix real model fields with declared ones freely.

Why this works: Exposing a computed "author_name" instead of a raw author id/nested object is a common API-shaping decision — ModelSerializer supports mixing auto-generated model fields with explicitly declared ones in the same fields list, which is what makes this kind of customization straightforward rather than requiring a full manual Serializer.

Using Meta.fields = "__all__" on a ModelSerializer for a model that will grow new fields later

Wrong

python
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = "__all__"   # includes password (hashed, but still), is_superuser, etc.

Better

python
class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["id", "username", "email", "first_name", "last_name"]

What you see: A newly added model field (e.g. an internal is_flagged_for_review boolean, or worse, a sensitive one) is automatically exposed over the API the moment it's added to the model — with no code change to the serializer itself, and no review step that would have caught it.

Why: "__all__" ties the API's exposed shape directly to the model's current field set, meaning any future model change silently changes the API contract too — an explicit field list requires a deliberate, reviewable code change to expose anything new, which is exactly the safety property "__all__" gives up for convenience.

DRF layers Django-shaped analogues over Django's own stack

Django

Form / ModelForm

View

DRF

Serializer / ModelSerializer

APIView / ViewSet

Router

  • Django
    • Form / ModelForm
    • View
  • DRF
    • Serializer / ModelSerializer
    • APIView / ViewSet
    • Router

DRF's Django-shaped analogues

DRF's Django-shaped analogues
Django conceptDRF equivalent
FormSerializer
ModelFormModelSerializer
View / class-based viewAPIView / generic view / ViewSet
urls.py path() listRouter (auto-generates URLs from a ViewSet)

Together

python
class ArticleSerializer(serializers.ModelSerializer):
    class Meta:
        model = Article
        fields = ["id", "title", "author", "published_at"]

Remember: DRF mirrors Django's own shapes: Serializer:Form, ModelSerializer:ModelForm, APIView/ViewSet:View. ModelSerializer infers field type and validators from the model, the same way ModelForm does — an explicit field list (never "__all__") keeps the API surface a deliberate, reviewable decision rather than tied automatically to the model's current fields. Always call is_valid() before reading .data/.validated_data on an input serializer.

See also: serializer validation · nested and context aware serializers · instance methods and domain logic

Advertisement

Validation, create(), and update()

The three-stage validation order, and the two methods serializer.save() actually calls.

validate_<field>(), validate(), create(), and update()

coreintermediate

Serializer validation runs in a defined order: each field's own built-in validators first, then any validate_<field_name>(self, value) method (single-field, custom logic), then validate(self, attrs) (cross-field logic, receiving every already-individually-validated field together) — collected into validated_data if everything passes, or raising ValidationError (individually or via serializer.errors) if not. create(self, validated_data) and update(self, instance, validated_data) are the two methods actually called by serializer.save() — ModelSerializer provides sensible defaults for both (a straightforward Model.objects.create()/instance.save() using validated_data), but overriding either is how custom save-time logic (setting a field from request context, handling a nested write) gets expressed.

Think of it as

The three-stage validation order (field validators → validate_<field> → validate()) exists because each stage answers a genuinely different question at a different point in the process: field-level validators check "is THIS value well-formed in isolation" (a valid email format, a value within a numeric range) without any awareness of the rest of the submission; validate_<field_name>() lets a single field's custom rule run with the same isolation but application-specific logic; validate() is the only stage that sees every field TOGETHER, because some rules are inherently relational ("end_date must be after start_date" cannot be checked by either field alone). Running them in this order — narrowest, most isolated checks first — means a cross-field validate() can trust that every individual field it receives has already passed its own, simpler checks, rather than needing to re-verify basic well-formedness itself. create()/update() being separate, overridable methods (rather than serializer.save() doing one hardcoded thing) exists because "what does saving actually mean" is not always just writing every validated field straight to the model — a password needs hashing before it's stored, a nested write needs to create related objects too, an update might need to preserve some server-controlled field regardless of what was submitted — and DRF's default implementations only cover the simple case correctly.

python
def validate_<field>(self, value): ...   # return value or raise
def validate(self, attrs): ...            # return attrs or raise
def create(self, validated_data): ...
def update(self, instance, validated_data): ...

What we're doing: Override create() to hash a password before saving, since ModelSerializer's default create() would otherwise store it as plain text.

accounts/serializers.pypython
class UserRegistrationSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ["username", "email", "password"]
        extra_kwargs = {"password": {"write_only": True}}

    def create(self, validated_data):
        user = User(username=validated_data["username"], email=validated_data["email"])
        user.set_password(validated_data["password"])
        user.save()
        return user
5
write_only=True on password means it's accepted on input but never included in output — a genuinely different concern from validation, but commonly configured alongside it.
7
set_password() (not a plain field assignment) is used specifically because the default ModelSerializer.create() would otherwise pass validated_data straight into User.objects.create(**validated_data), storing the raw password string.

Why this works: The default create() DRF would generate for ModelSerializer has no idea "password" needs special handling — it treats every field the same way, straight into the model constructor — so any field needing save-time transformation (hashing, a computed default, a side effect) requires an explicit create()/update() override.

Putting a cross-field rule inside validate_<field_name>() instead of validate()

Wrong

python
def validate_end_date(self, value):
    if value <= self.initial_data.get("start_date"):   # reaching for another field awkwardly
        raise serializers.ValidationError("end_date must be after start_date.")
    return value

Better

python
def validate(self, attrs):
    if attrs["end_date"] <= attrs["start_date"]:
        raise serializers.ValidationError("end_date must be after start_date.")
    return attrs

What you see: Reaching into self.initial_data (the RAW, pre-validation input dict) from inside a single-field validator works by accident some of the time, but bypasses the normal validated-value guarantees — if start_date itself failed its own validation, initial_data still has whatever raw, invalid value the client sent, silently comparing against unvalidated data.

Why: validate_<field_name>() is documented as receiving only that ONE field's already-validated value — it has no clean, supported way to access another field's validated value, only the raw initial_data dict. validate() is the stage specifically designed to receive every field's validated value together, which is why any rule spanning more than one field belongs there, not shoehorned into a single-field validator via a workaround.

Validation runs narrowest to broadest

Field validators

one field, in isolation

validate_<field>()

one field, custom logic

validate(attrs)

every field together — cross-field rules

create() / update()

validated_data, safe to trust

  • Field validators — one field, in isolation
    • leads to validate_<field>()
  • validate_<field>() — one field, custom logic
    • leads to validate(attrs)
  • validate(attrs) — every field together — cross-field rules
    • leads to create() / update()
  • create() / update() — validated_data, safe to trust

The three validation stages, in order

The three validation stages, in order
StageSeesGood for
Field validatorsone field's raw valueformat/range checks Django/DRF already knows
validate_<field>(self, value)one field's raw valuea single field's custom, application-specific rule
validate(self, attrs)every field's validated value togethera genuinely cross-field/relational rule

Together

python
class EventSerializer(serializers.ModelSerializer):
    def validate_capacity(self, value):
        if value <= 0:
            raise serializers.ValidationError("Capacity must be positive.")
        return value

    def validate(self, attrs):
        if attrs["end_date"] <= attrs["start_date"]:
            raise serializers.ValidationError("end_date must be after start_date.")
        return attrs

Remember: Validation runs field validators → validate_<field>() → validate(), narrowest to broadest — a genuinely cross-field rule belongs in validate(), never in a single-field validator reaching into initial_data. create()/update() are what serializer.save() actually calls; ModelSerializer's defaults handle the simple case, but anything needing save-time transformation (hashing, nested writes) requires an explicit override — DRF raises NotImplementedError for a writable nested field rather than guessing.

See also: drf architecture and modelserializer · nested and context aware serializers · custom validation

Advertisement

Nested serializers, context, and dynamic shapes

Read/write-only fields, request-scoped context, custom fields, and serving multiple view shapes from one class.

Nested serializers, read/write-only fields, context, and dynamic shapes

coreadvanced

A nested serializer (one serializer used as a FIELD on another) represents a relationship as a full embedded object in the JSON output, not just an id — read-only by default for a relationship, writable only with an explicit create()/update() override (DRF refuses to guess). read_only=True excludes a field from input entirely (accepted nowhere, always in output); write_only=True is the reverse (accepted on input, never in output — the standard way to handle a password field). context is a dict passed into a serializer's constructor (commonly {"request": request} from a view's get_serializer_context()) — the only clean way for serializer logic (a custom field, a validate() method) to access the current request, e.g. to build an absolute URL or check request.user. Custom serializer fields (subclassing serializers.Field, or the simpler SerializerMethodField for read-only computed values) handle representations DRF's built-in field types don't cover. Dynamic serializers adjust their own field set at runtime (commonly via a custom __init__ accepting a fields kwarg) — useful for one serializer class serving both a lightweight list view and a fuller detail view.

Think of it as

Nested serializers, read/write-only fields, and context are all instances of the same underlying idea: a serializer's job is to represent a MODEL'S data in whatever shape an API actually needs, and that shape is frequently not "one flat object with the model's exact fields." Nesting exists because relationships (a foreign key, a reverse relation) are naturally hierarchical data, and sometimes an API consumer genuinely wants the related object's FULL representation inline, not just its id, saving a second request — DRF supports this cleanly for reads because rendering nested JSON is unambiguous, but refuses to auto-support nested WRITES because there are multiple, equally reasonable ways to interpret "update this order's nested items" (replace all, diff, reject), which only application code can decide correctly. read_only/write_only exist because a field's presence in the SCHEMA (what the serializer knows about) and its presence in a given DIRECTION (input vs output) are genuinely different questions — a password is very much part of the User model's shape, just never something that should round-trip back out in a response. context solves a structural problem: a serializer instance is created and used somewhere fairly removed from the view/request that triggered it, yet serializer logic sometimes genuinely needs request-scoped information (who's asking, what's the base URL) — passing a context dict explicitly is the documented, clean channel for that, rather than serializers reaching for some global/thread-local request object. Dynamic serializers exist because rigidly tying one serializer class to one exact field set doesn't scale once an API has multiple views of the same data (a list endpoint wanting a slim shape, a detail endpoint wanting everything) — parameterizing fields at __init__ time avoids either duplicating near-identical serializer classes or over-fetching/over-exposing data the slimmer view didn't need.

python
class MySerializer(serializers.ModelSerializer):
    computed = serializers.SerializerMethodField()

    def get_computed(self, obj):
        return obj.some_calculation()

What we're doing: Use context to build an absolute URL in a computed field, and a dynamic fields pattern so the same serializer serves both a list and a detail view.

articles/serializers.pypython
class ArticleSerializer(serializers.ModelSerializer):
    absolute_url = serializers.SerializerMethodField()

    class Meta:
        model = Article
        fields = ["id", "title", "absolute_url"]

    def __init__(self, *args, fields=None, **kwargs):
        super().__init__(*args, **kwargs)
        if fields is not None:
            for name in set(self.fields) - set(fields):
                self.fields.pop(name)

    def get_absolute_url(self, obj):
        request = self.context.get("request")
        return request.build_absolute_uri(obj.get_absolute_url()) if request else obj.get_absolute_url()
3
SerializerMethodField pairs with get_absolute_url() by naming convention — get_<field_name>, called automatically during serialization.
9
fields=None (not part of Meta.fields) is a runtime kwarg — ArticleSerializer(instance, fields=["id", "title"]) trims the field set for a slimmer list view.
17
self.context.get("request") relies on the view passing {"request": request} via get_serializer_context() — without it, this falls back to a relative URL rather than crashing.

Why this works: Building an absolute (not relative) URL genuinely requires knowing the request's host — context is the only clean channel a serializer has for that, since a Serializer instance itself has no other route back to the request that triggered its use.

Making a relationship field writable via a nested serializer without overriding create()/update()

Wrong

python
class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True)   # not read_only — expecting it to be writable

    class Meta:
        model = Order
        fields = ["id", "items"]
# POST/PUT with nested items data raises NotImplementedError on .save()

Better

python
class OrderSerializer(serializers.ModelSerializer):
    items = OrderItemSerializer(many=True, read_only=True)   # reads are fine as-is
    # writing items happens through a SEPARATE, explicit endpoint/serializer instead

What you see: NotImplementedError: The `.create()` method does not support writable nested fields by default — a hard error on save(), not a silently wrong result, but still a common early DRF mistake when a nested field is added without realizing writes need explicit support.

Why: DRF intentionally does not guess at nested-write semantics, since there is no single correct default (replace entirely vs. diff vs. reject) — read_only=True on the nested field is the honest default until/unless a real create()/update() override is written to define exactly how a nested write should behave.

A nested field, read-only by default, plus its Meta

class OrderSerializer(serializers.ModelSerializer): customer = CustomerSerializer(read_only=True) class Meta: model = Order fields = ["id", "customer", "total"]

CustomerSerializer(read_only=True)

nested serializer — the full related object, output only — no create()/update() means input is refused

fields = ["id", "customer", "total"]

field list — the deliberate, reviewable API surface

  • Whole: class OrderSerializer(serializers.ModelSerializer): customer = CustomerSerializer(read_only=True) class Meta: model = Order fields = ["id", "customer", "total"]
  • CustomerSerializer(read_only=True) — nested serializer: the full related object, output only — no create()/update() means input is refused
  • fields = ["id", "customer", "total"] — field list: the deliberate, reviewable API surface

read_only vs write_only vs a nested serializer

read_only vs write_only vs a nested serializer
MechanismAppears in input?Appears in output?
read_only=Truenoyes
write_only=Trueyesno
A nested serializer (default)no (unless create()/update() overridden)yes — the full related object

Together

python
class OrderSerializer(serializers.ModelSerializer):
    customer = CustomerSerializer(read_only=True)   # nested, full object on output

    class Meta:
        model = Order
        fields = ["id", "customer", "total"]

Remember: A nested serializer is read-only by default for a relationship — DRF raises NotImplementedError rather than guessing at nested-write semantics; make it writable only with an explicit create()/update(). read_only excludes from input, write_only excludes from output. context is the clean channel for request-scoped data inside serializer logic — always access it with .get(), never an assumed key. SerializerMethodField is the simplest computed, read-only field; a dynamic fields kwarg in __init__ lets one serializer class serve multiple view shapes.

See also: serializer validation · drf architecture and modelserializer

Advertisement