Вложенные сериализаторы в DRF: правильно с первого раза
Вложенный сериализатор в Django REST Framework отдаёт данные без проблем, а на запись падает с ошибкой или молча игнорирует данные. Разбираем, где именно ломается и как это чинить.

Вложенный сериализатор в DRF красиво отдаёт связанные объекты на чтение — заказ с позициями, автор с профилем — и почти всегда ломается на запись. При POST вы получаете либо IntegrityError из-за незаполненного внешнего ключа, либо тихую потерю вложенных данных: объект создаётся, а связанные записи — нет. Причина в том, что DRF по умолчанию не умеет сохранять вложенные структуры, и об этом прямо сказано в документации. Разберём, где очевидное решение даёт проблему и какие подходы работают на запись.
Всё ниже — для Django REST Framework 3.14 / 3.15 и Django 4.2–5.x. Между этими версиями поведение вложенной записи не менялось: ответственность за create и update лежит на вас.
Задача и почему очевидное не работает
Пусть есть заказ и его позиции: одна модель Order, много OrderItem через ForeignKey. На чтение хочется отдавать заказ вместе с позициями одним объектом. Первое, что приходит в голову — вложить сериализатор:
from rest_framework import serializers
from .models import Order, OrderItem
class OrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = OrderItem
fields = ["id", "product", "quantity", "price"]
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
class Meta:
model = Order
fields = ["id", "customer", "created_at", "items"]На GET это работает. На POST с телом, где есть массив items, DRF возбудит исключение вида The .create() method does not support writable nested fields by default. Фреймворк валидирует вложенные данные, кладёт их в validated_data, но не знает, как их сохранить: у него нет предположения о том, создавать позиции, обновлять или удалять.
Что именно происходит внутри
Метод create() у ModelSerializer вызывает Model.objects.create(**validated_data). Если в validated_data есть ключ items со списком словарей, Django попытается передать список в конструктор модели Order, где такого поля нет. Отсюда падение. Чтобы этого не случилось, вложенные данные нужно вынуть из validated_data до создания родителя и обработать отдельно.
Подход 1: writable nested через переопределённый create/update
Самый прямой путь — переопределить create() и update() в родительском сериализаторе. Данные извлекаются через pop, родитель создаётся, потом создаются дети.
from django.db import transaction
from rest_framework import serializers
from .models import Order, OrderItem
class OrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = OrderItem
fields = ["id", "product", "quantity", "price"]
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
class Meta:
model = Order
fields = ["id", "customer", "created_at", "items"]
@transaction.atomic
def create(self, validated_data):
# вынимаем вложенные данные до создания родителя
items_data = validated_data.pop("items")
order = Order.objects.create(**validated_data)
for item in items_data:
OrderItem.objects.create(order=order, **item)
return orderОбратите внимание на @transaction.atomic: без него сбой при создании третьей позиции оставит в базе заказ и две позиции. Это тихая порча данных, которую потом ловят по жалобам, а не по логам.
Обновление — отдельная головная боль
С update() сложнее. Пришёл список items — что делать со старыми? Три сценария, и DRF ни один за вас не выберет:
- полная замена — удалить все позиции заказа и создать заново из тела запроса;
- частичное обновление — сопоставить по
id, существующие обновить, новые создать, отсутствующие в теле удалить; - только добавление — старые не трогать.
Частичное обновление по id — самый частый рабочий вариант:
@transaction.atomic
def update(self, instance, validated_data):
items_data = validated_data.pop("items", None)
# обновляем поля самого заказа
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
if items_data is not None:
existing = {item.id: item for item in instance.items.all()}
sent_ids = set()
for item in items_data:
item_id = item.get("id")
if item_id and item_id in existing:
obj = existing[item_id]
for attr, value in item.items():
setattr(obj, attr, value)
obj.save()
sent_ids.add(item_id)
else:
OrderItem.objects.create(order=instance, **item)
# удаляем позиции, которых нет в запросе
for item_id, obj in existing.items():
if item_id not in sent_ids:
obj.delete()
return instanceЗдесь есть подвох: чтобы item.get("id") вообще приходил в validated_data, поле id во вложенном сериализаторе не должно быть read_only. У ModelSerializer по умолчанию id read-only, и тогда клиент его пришлёт, а до validated_data оно не дойдёт. Объявите его явно: id = serializers.IntegerField(required=False).
Подход 2: разные сериализаторы на чтение и запись
Часто вложенность нужна только на чтение, а на запись достаточно передать id связанного объекта. Тогда не мучайте один сериализатор двумя режимами — используйте PrimaryKeyRelatedField для записи и вложенный сериализатор для отдачи.
class OrderSerializer(serializers.ModelSerializer):
# на запись клиент шлёт id товара
product = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all(), write_only=True
)
# на чтение отдаём вложенный объект
product_detail = ProductSerializer(source="product", read_only=True)
class Meta:
model = Order
fields = ["id", "product", "product_detail", "quantity"]Такой сериализатор не требует переопределять create: PrimaryKeyRelatedField кладёт в validated_data готовый объект модели, и стандартный create() справляется сам. Минус — клиент получает и product (пустое на чтение), и product_detail. Для чистого контракта разведите чтение и запись на два разных сериализатора и выбирайте нужный в get_serializer_class().
Подход 3: сторонняя библиотека
Если вложенная запись встречается в проекте часто, писать create/update руками для каждого сериализатора утомительно. Существует пакет drf-writable-nested, который добавляет миксины WritableNestedModelSerializer и берёт на себя рекурсивное сохранение. Проверьте совместимость с вашей версией DRF по странице пакета на PyPI перед установкой — библиотека сторонняя и обновляется своим темпом.
Правило простое: если вложенность нужна только на чтение — не делайте поле writable. Половина проблем с вложенными сериализаторами возникает там, где запись вообще не требовалась.
Когда что выбирать
| Ситуация | Подход |
|---|---|
| Вложенность только на чтение | read_only=True на вложенном поле, ничего не переопределять |
| На запись достаточно id связанного объекта | PrimaryKeyRelatedField + отдельный сериализатор на чтение |
| Нужно создавать/обновлять детей вместе с родителем | Свои create/update с transaction.atomic |
| Вложенная запись по всему проекту | drf-writable-nested |
Что ломается чаще всего
- Нет транзакции. Частичный сбой оставляет родителя без части детей. Оборачивайте
createиupdateв@transaction.atomic. - id read-only при обновлении. Клиент шлёт
idпозиции, вы его не видите вvalidated_data, все позиции создаются заново вместо обновления. - N+1 на чтение. Вложенный сериализатор для
many=Trueбезprefetch_relatedв queryset даёт по запросу на каждого родителя. На списке из 50 заказов это 51 запрос. - Валидация вложенных данных. Ошибки во вложенных полях DRF возвращает вложенной структурой ошибок — фронтенд должен уметь её разобрать, иначе пользователь увидит «что-то пошло не так».
N+1 лечится в queryset вьюхи, а не в сериализаторе:
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.prefetch_related("items").all()
serializer_class = OrderSerializerЧто делать дальше
Начните с честного ответа на вопрос: нужна ли запись вообще. Если только чтение — поставьте read_only=True и закройте тему. Если нужна — выберите между PrimaryKeyRelatedField (когда достаточно id) и своими create/update (когда дети создаются вместе с родителем). После этого включите на списочных эндпоинтах prefetch_related и проверьте число SQL-запросов через django-debug-toolbar или connection.queries в тесте — вложенные сериализаторы дают N+1 незаметно. Инвалидация кеша и версионирование API вложенных ответов — отдельные темы, здесь мы их не трогали.
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: Django REST Framework — Serializers (writable nested representations), Django REST Framework — Relations
Частые вопросы
Почему POST с вложенными данными падает с ошибкой про writable nested fields?
У меня другая версия DRF, код будет работать?
Сработало на создание, но при обновлении все позиции пересоздаются заново — почему?
Стоит ли использовать drf-writable-nested вместо ручных create/update?
Как избежать N+1 запросов при вложенном чтении списка?
Нужна ли транзакция, если у меня всего пара вложенных объектов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.