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
PATCHmean 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()intransaction.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 →TypeErrorfromModel(**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()andupdate()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
idoptional and writable in the nested serializer; inupdate(), delete missing ids, update present ids (re-scoped to parent), create id-less items. Wrap intransaction.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.