Filter concepts by levelShowing all levels.

Django · Django REST Framework

Django REST Framework

Serializers and ModelSerializer, the three-stage validation order, and nested/context-aware fields

  • DRF mirrors Django's own shapesSerializer:Form, ModelSerializer:ModelForm, ViewSet:View
  • Validation runs narrowest to broadestfield validators → validate_<field>() → validate(), which sees everything together
  • create()/update() are what .save() actually callsoverride either for save-time transforms — hashing, nested writes
  • A nested serializer is read-only by defaultDRF refuses to guess at nested-write semantics — override create()/update() explicitly

1.DRF architecture, Serializers, ModelSerializer, and fields

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.

Key features

  • A plain Serializer requires every field declared explicitly (like Form) — full control, more boilerplate; ModelSerializer auto-generates fields from Meta.model + Meta.fields (like ModelForm) — less boilerplate, less explicit
  • ModelSerializer infers field TYPE and VALIDATORS from the model field automatically — a model's CharField(max_length=100) becomes a serializer CharField with an automatic max_length=100 validator, without repeating it
  • Meta.fields = "__all__" is supported but discouraged in DRF's own docs for anything beyond a quick prototype — an explicit field list is the documented recommendation, since it avoids silently exposing a newly-added model field over the API
  • serializer.data (read) is only meaningful AFTER validation for deserialization, or directly for serialization — accessing .data before calling is_valid() on a bound serializer being used for input raises an AssertionError, a common early mistake
  • DRF ships a broad field-type library (CharField, IntegerField, SlugRelatedField, PrimaryKeyRelatedField for relationships, etc.) — ModelSerializer picks a sensible default per model field type, but an explicit field declaration always overrides the inferred one
  • DRF is a separate INSTALLED_APPS entry (rest_framework) with its own settings namespace (REST_FRAMEWORK = {...} in settings.py) — it layers on top of Django, it does not replace any part of Django's own request/URL/model machinery

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)
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

Command

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

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.

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

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.

Key features

  • Order: each field's own validators run first, then validate_<field_name>(self, value) for that field specifically, then validate(self, attrs) once every field has individually passed — attrs contains every field's already-validated value together
  • validate_<field_name>() must return the (possibly transformed) value, or raise serializers.ValidationError — returning None where a value was expected silently sets that field to None
  • validate(self, attrs) must return attrs (again, possibly modified) — it is the only place where a genuinely cross-field rule (a date range, "at least one of these two fields must be set") can be checked, since it is the only stage receiving everything at once
  • serializer.save() calls create() for a new instance (no instance was passed to the serializer) or update() for an existing one — ModelSerializer's defaults handle the straightforward case; override either to customize save-time behavior
  • validated_data passed into create()/update() has ALREADY passed every validation stage — it is safe to trust the shape and types, though it may still need transformation (hashing a password, popping a field that shouldn't be saved directly)
  • A read-only field (declared read_only=True, or auto-inferred for things like an auto_now_add timestamp) is excluded from validated_data entirely — it can never be set via input, regardless of what the client submits

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
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

Command

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): ...

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.

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

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.

Key features

  • A nested serializer field defaults to READ-ONLY for a relationship unless explicitly made writable with a custom create()/update() — DRF raises NotImplementedError rather than guessing how a nested write should behave
  • read_only=True fields never appear in validated_data (excluded from input entirely, even if the client submits a value for them) — write_only=True fields never appear in .data (excluded from output, even though they're accepted and validated on input)
  • self.context (available inside any serializer method) is populated from whatever dict was passed to the serializer's constructor — a generic view's get_serializer_context() by default includes {"request", "view", "format"}
  • SerializerMethodField (paired with a get_<field_name>(self, obj) method) is the simplest way to add a read-only, computed field — it always renders via that method, and is inherently read-only (there's no corresponding way to accept it as input)
  • A custom Field subclass overrides to_representation() (model value → JSON-safe output) and to_internal_value() (raw input → validated Python value) — the two directions a built-in field type already handles, needed only when no existing field type fits
  • A dynamic serializer commonly overrides __init__(self, *args, fields=None, **kwargs) to pop unwanted keys from self.fields after calling super().__init__() — this is a documented DRF cookbook pattern, not a built-in feature, since no single "dynamic fields" class ships with DRF itself

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
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

Command

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

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

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.

Remember

  • ModelSerializer infers field type and validators from the model, mirroring ModelForm — use an explicit field list, never "__all__", so the API surface is a deliberate decision.
  • Validation order is field validators, then validate_<field>(), then validate() — a genuinely cross-field rule belongs only in validate(), which is the sole stage seeing every field together.
  • create()/update() are what serializer.save() actually calls — override either for save-time logic a straight field-to-model copy can't express, like password hashing.
  • read_only excludes a field from input; write_only excludes it from output. context is the clean channel for request-scoped data inside serializer logic — always .get(), never an assumed key.

Pro tip

  • SerializerMethodField is the simplest way to add a computed, always-read-only field via a paired get_<field_name>() method.
  • A dynamic fields kwarg in __init__ lets one serializer class serve both a slim list view and a fuller detail view, without duplicating near-identical classes.

Next up

  • DRF Views
  • DRF Authentication
  • DRF Permissions

Sources

  • DRF Serializers — django-rest-framework.org/api-guide/serializers/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-08-24

Spotted a mistake on this page? Tell us.