Nested Writes in Django REST Framework: Why ModelSerializer Fails and the Explicit Fix
DRF's ModelSerializer silently drops or rejects nested JSON on writes. Learn the supported override pattern for create() and update(), why transaction.atomic() matters, and when to stop nesting.
29 Jun 2026, 06:53 UTC

You send a POST request with a nested JSON payload—an author and their books in one body—and DRF either returns a validation error or saves the author while silently discarding the books. The useful takeaway: Django REST Framework's ModelSerializer does not implement nested writes. You must handle them explicitly by overriding create() and update().
Why ModelSerializer Ignores Nested Data
When you declare a relational field on a ModelSerializer without specifying a field class, DRF defaults to PrimaryKeyRelatedField for writable relations. That means the serializer expects a scalar primary key in the input, not a nested dictionary. If you declare a nested serializer with many=True or many=False, the input format changes to nested objects, but the default create() and update() methods still expect scalar values. The nested dictionaries end up in validated_data, and when ModelSerializer passes that data to Model.objects.create(), the ORM cannot resolve them.
The result is either an AttributeError, a TypeError, or—depending on the DRF version and field configuration—the nested data is simply ignored. DRF treats nested representations as a read convenience, not a write contract.
The Supported Pattern: Override and Pop
The documented approach is to take control of the write path. Override create() and update() on your serializer, pop the nested data out of validated_data, save the parent instance, then create or update the children explicitly. Because this bypasses ModelSerializer's automatic field mapping, you are responsible for ensuring every model field is accounted for.
Wrap the logic in transaction.atomic(). If a child record fails validation or a database constraint rejects an insert, the parent creation rolls back too. Without it, a failed child leaves an orphaned parent and a half-written API response.
Worked Example: Authors and Books
Assume a minimal project with two models where Book has a ForeignKey to Author.
# models.py
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, related_name="books", on_delete=models.CASCADE)
Run migrations from your project root (the directory containing manage.py):
python manage.py makemigrations
python manage.py migrate
Now define a serializer that handles the nested write explicitly:
# serializers.py
from rest_framework import serializers
from django.db import transaction
from .models import Author, Book
class AuthorSerializer(serializers.ModelSerializer):
books = serializers.ListField(write_only=True, required=False)
class Meta:
model = Author
fields = ["id", "name", "books"]
def create(self, validated_data):
books_data = validated_data.pop("books", [])
with transaction.atomic():
author = Author.objects.create(**validated_data)
for book_data in books_data:
Book.objects.create(author=author, **book_data)
return author
def update(self, instance, validated_data):
books_data = validated_data.pop("books", None)
with transaction.atomic():
instance.name = validated_data.get("name", instance.name)
instance.save()
if books_data is not None:
instance.books.all().delete()
for book_data in books_data:
Book.objects.create(author=instance, **book_data)
return instance
Note the use of serializers.ListField here. You can also declare a nested BookSerializer with many=True—DRF requires an explicit nested serializer to accept nested objects instead of primary keys—but the pop-and-loop pattern gives you full control over child creation and avoids the automatic field-mapping surprises that come with deeply nested ModelSerializer fields.
Wire up a standard view:
# views.py
from rest_framework import generics
from .models import Author
from .serializers import AuthorSerializer
class AuthorListCreate(generics.ListCreateAPIView):
queryset = Author.objects.prefetch_related("books")
serializer_class = AuthorSerializer
Test the endpoint from your terminal (replace localhost:8000 with your actual host and port):
curl -X POST http://localhost:8000/authors/ \
-H "Content-Type: application/json" \
-d '{"name":"Ursula K. Le Guin","books":[{"title":"A Wizard of Earthsea"},{"title":"The Left Hand of Darkness"}]}'
Expected check: the response body should include the new author's id and name. Query the database to confirm the books exist:
python manage.py shell -c "from library.models import Author; a = Author.objects.get(name='Ursula K. Le Guin'); print(a.books.count())"
The output should be 2. If it is 0, the nested data was not processed—confirm that your view is using the custom serializer and that the payload key matches the serializer field name.
Trade-offs, Limitations, and When to Stop Nesting
Explicit overrides work, but they carry costs:
| Concern | Impact | Mitigation |
|---|---|---|
| Manual field mapping | Typos or missing model fields in validated_data cause runtime errors | Keep serializers close to models; add tests that assert all model fields are writable |
| Deep nesting | Three or more levels multiply boilerplate and make the code hard to audit | Limit nesting to one level; use flat IDs or separate endpoints for deeper hierarchies |
| Query volume | Looping over children triggers one INSERT per record | Use bulk_create for large collections; use select_related and prefetch_related for reads |
| Update semantics | Deciding whether to delete missing children, update matches, or ignore extras is business logic DRF cannot infer | Document the chosen semantics (e.g., replace-all, merge, or strict match) in the serializer docstring |
One practical limit: if your payload nests more than one level deep—say an author with books that each have a list of reviews—the override pattern becomes fragile. At that point, flat primary-key references or separate endpoints are usually more maintainable than a single mega-serializer.
Actionable Checklist
- Audit existing
ModelSerializerendpoints for nested writable fields. If the defaultcreate()is still active, nested writes are either failing or being ignored. - Add
transaction.atomic()around any custom write logic before deploying to production. - Pin your DRF version in
requirements.txtand check the release notes when upgrading. Serializer method signatures and validation internals can shift between major releases. - Write a test that posts a nested payload and asserts both parent and child rows exist. Add a second test that forces a child failure and asserts the parent was not persisted.
Nested writes in DRF are not a missing feature; they are a deliberate boundary. The framework gives you validated data and expects you to decide how it maps to your database. Making that decision explicit—with transactions, clear limits on depth, and tests that verify rollback—is the difference between an API that silently corrupts data and one that fails safely.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.