Backend / Web frameworks / Django / DRF / 11_nested_writable_serializers.md

DRF Nested Writable Serializers

Updated 5 interview angles 4 min read source
On this page12
  1. Read-only nested (the easy case)
  2. Why writable nested isn’t automatic
  3. Pattern: nested-create on parent create
  4. Pattern: replace-all nested update
  5. Pattern: id-aware upsert
  6. Writable FK-by-id (the simpler 80% case)
  7. source for renaming
  8. Many-to-many writable
  9. Validation on nested input
  10. When NOT to use writable nested
  11. Pitfalls summary
  12. Interview angle

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)

python
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 DRF Performance and the N+1 Problem).

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

python
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

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

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

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

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

For nested fields:

python
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():

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

json
{"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 5

  • “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.