backend / web frameworks / django / drf / 11_nested_writable_serializers.md

DRF Nested Writable Serializers

5 interview angles 4 min read source

DRF Nested Writable Serializers

Nested serializers are easy to read, hard to write. DRF auto-handles the read direction; for the write direction you have to override create() and update() yourself, and decide on semantics that DRF intentionally doesn’t pick for you.

Read-only nested (the easy case)

class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ["id", "body", "author"]

class PostSerializer(serializers.ModelSerializer):
    comments = CommentSerializer(many=True, read_only=True)
    class Meta:
        model = Post
        fields = ["id", "title", "comments"]

read_only=True means clients see comments embedded but can’t write them. Combine with select_related/prefetch_related or you get N+1 (see 12_performance_n_plus_1.md).

Why writable nested isn’t automatic

Given POST /posts/ with {"title": "x", "comments": [{"body": "a"}, {"body": "b"}]}, DRF can’t know:

  • Should existing comments be deleted on PUT? Kept? Replaced only by id?
  • What about partial updates — what does PATCH mean for the nested array?
  • If a nested item has an id, is that an update reference or a client-supplied id?

So DRF requires you to write create() / update() and choose semantics.

Pattern: nested-create on parent create

class CommentSerializer(serializers.ModelSerializer):
    class Meta:
        model = Comment
        fields = ["id", "body"]

class PostSerializer(serializers.ModelSerializer):
    comments = CommentSerializer(many=True)

    class Meta:
        model = Post
        fields = ["id", "title", "comments"]

    def create(self, validated_data):
        comments_data = validated_data.pop("comments", [])
        post = Post.objects.create(**validated_data)
        Comment.objects.bulk_create([
            Comment(post=post, **c) for c in comments_data
        ])
        return post

If you don’t pop nested data, Post.objects.create(**validated_data) raises TypeError: Post() got unexpected keyword argument 'comments'.

Pattern: replace-all nested update

def update(self, instance, validated_data):
    comments_data = validated_data.pop("comments", None)
    for attr, value in validated_data.items():
        setattr(instance, attr, value)
    instance.save()

    if comments_data is not None:
        instance.comments.all().delete()
        Comment.objects.bulk_create([
            Comment(post=instance, **c) for c in comments_data
        ])
    return instance

Trade-off: simple, destructive. Loses comment ids on every update; breaks foreign-keyed-from elsewhere.

Pattern: id-aware upsert

When the client sends id for existing items and omits it for new ones:

def update(self, instance, validated_data):
    comments_data = validated_data.pop("comments", [])
    for attr, value in validated_data.items():
        setattr(instance, attr, value)
    instance.save()

    incoming_ids = {c["id"] for c in comments_data if "id" in c}
    instance.comments.exclude(id__in=incoming_ids).delete()  # remove orphans

    for c in comments_data:
        if "id" in c:
            Comment.objects.filter(id=c["id"], post=instance).update(**{
                k: v for k, v in c.items() if k != "id"
            })
        else:
            Comment.objects.create(post=instance, **c)
    return instance

Make id writable: id = serializers.IntegerField(required=False) in the nested serializer.

Issues to watch:

  • Authorization on incoming ids. A malicious client could send {"id": <comment_belonging_to_another_post>} and re-attach it. Always re-scope: Comment.objects.filter(id=c["id"], post=instance).
  • Transactions. Wrap update() in transaction.atomic() — partial failures otherwise leave half-updated trees.

Writable FK-by-id (the simpler 80% case)

For one-to-many writes, you usually don’t need a writable nested serializer — just expose the FK as an id field:

class CommentSerializer(serializers.ModelSerializer):
    post = serializers.PrimaryKeyRelatedField(queryset=Post.objects.all())
    class Meta:
        model = Comment
        fields = ["id", "post", "body"]

Client posts {"post": 42, "body": "..."}. No nesting, no overrides needed. Use this when the client already knows the parent id.

source for renaming

Sometimes the API key shouldn’t match the model attribute:

class UserSerializer(serializers.ModelSerializer):
    name = serializers.CharField(source="full_name")
    class Meta:
        model = User
        fields = ["id", "name"]

For nested fields:

author_name = serializers.CharField(source="author.name", read_only=True)

Dotted source = traverse the relationship. Read-only because writing through a dotted source is ambiguous.

Many-to-many writable

M2M is easier than reverse FK because Django provides set():

class BookSerializer(serializers.ModelSerializer):
    tags = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.all())
    class Meta:
        model = Book
        fields = ["id", "title", "tags"]

DRF’s ModelSerializer.create / update handle M2M: extract the list, call book.tags.set(ids) after book.save(). No override needed if you just want id-based M2M.

For writable nested M2M with full nested objects (not just ids), same pattern as one-to-many — pop, save parent, set the relation.

Validation on nested input

Each nested serializer’s validate_<field> and validate() run as part of the parent’s is_valid(). Errors come back nested:

{"comments": [{"body": ["This field is required."]}, {}, {}]}

The position in the array matches the input array.

When NOT to use writable nested

  • The relationship is large (hundreds of children) — split into separate endpoints.
  • Children have their own permissions/lifecycle — give them their own viewset.
  • The write path is rare — flat input + extra calls is simpler than 60 lines of update().

A common refactor is “stop nesting on writes; use separate endpoints.” It’s almost always cleaner.

Pitfalls summary

  • Forgetting to .pop() nested data → TypeError from Model(**validated_data).
  • Replace-all update() → loses ids and breaks downstream FKs.
  • No transaction.atomic() → half-updated trees on errors.
  • Trusting client-supplied nested ids → cross-tenant data leak.
  • Read-only nested without prefetch_related → N+1.

Interview angle

  • “Why does DRF make you implement create() and update() for writable nested serializers?” — semantics aren’t obvious (replace? merge? upsert?), so DRF refuses to guess.
  • “How would you write an upsert for a nested array?” — make id optional and writable in the nested serializer; in update(), delete missing ids, update present ids (re-scoped to parent), create id-less items. Wrap in transaction.atomic.
  • “Why is read-only nested fine but writable nested often a code smell?” — large or independently-owned children should have their own endpoints; nested writes hide N+1, validation blow-up, and authorization holes.
  • “What’s a security risk specific to nested writable serializers?” — accepting client-supplied ids without re-scoping to the parent — cross-tenant attach.
  • “When can you avoid writing nested logic entirely?” — when the FK can be sent by id (PrimaryKeyRelatedField); for M2M, PrimaryKeyRelatedField(many=True) plus DRF’s default M2M handling.