Вложенные сериализаторы в DRF: сравниваем четыре подхода
Один эндпоинт заказа тянет за собой позиции, товары и покупателя. Как отдавать вложенные данные в DRF без сотен запросов и не сломать запись — четыре подхода с ценой каждого.

Эндпоинт /api/orders/ отдаёт заказ с позициями, а внутри каждой позиции — товар и его категория. На проде видите: один запрос списка из 50 заказов порождает 300 SQL-запросов, ответ отдаётся за 900 мс, а на попытке создать заказ через тот же сериализатор прилетает The `.create()` method does not support writable nested fields by default. Разберём, какими способами в DRF описывают вложенность, чем каждый платит и когда какой выбирать. Всё проверено на Django REST Framework 3.15 и Django 5.x.
Речь только про выбор способа сериализации связей. Пагинацию вложенных списков, кастомную инвалидацию кеша и GraphQL-подобные подходы вроде django-restql не трогаем — это отдельные темы.
Задача и почему очевидное решение подводит
Возьмём три модели: Order, OrderItem (FK на заказ и на товар) и Product. На чтении хочется отдавать заказ вместе с развёрнутыми позициями и товарами. На записи — принимать заказ и его позиции одним POST.
Очевидное «просто вложить сериализатор в сериализатор» решает чтение, но:
- по умолчанию ORM грузит связи лениво — на каждую позицию отдельный запрос к
product, отсюда N+1; - вложенный сериализатор по умолчанию read-only при записи, а самописный
create()/update()для вложенных списков быстро становится источником багов с частичным обновлением.
Поэтому подход выбирают под операцию: то, что удобно на чтении, часто мешает на записи.
Подход 1. Вложенный сериализатор (nested read-only)
Классика для чтения: указываете сериализатор поля напрямую.
from rest_framework import serializers
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ["id", "title", "price"]
class OrderItemSerializer(serializers.ModelSerializer):
product = ProductSerializer(read_only=True) # разворачиваем товар целиком
class Meta:
model = OrderItem
fields = ["id", "product", "quantity"]
class OrderSerializer(serializers.ModelSerializer):
# related_name="items" на FK в OrderItem
items = OrderItemSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ["id", "created_at", "items"]Чтобы не словить N+1, во вьюхе явно подгружаете связи:
from rest_framework import viewsets
class OrderViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderSerializer
# prefetch_related для обратной связи many, select_related для FK внутри
queryset = (
Order.objects
.prefetch_related("items__product")
)Выигрыш: самый читаемый код, полный контроль над формой ответа. Цена: запись руками; забудете prefetch_related — вернётся N+1.
Подход 2. PrimaryKeyRelatedField — отдаём только ID
Если фронт сам держит справочник товаров, разворачивать их незачем — отдайте ID.
class OrderItemSerializer(serializers.ModelSerializer):
product = serializers.PrimaryKeyRelatedField(
queryset=Product.objects.all()
)
class Meta:
model = OrderItem
fields = ["id", "product", "quantity"]Выигрыш: компактный ответ, поле сразу writable — на записи принимает ID и валидирует существование. Цена: клиенту нужен отдельный запрос за деталями товара. Для FK select_related("product") всё равно поставьте — DRF дёрнет объект при валидации.
Подход 3. Разные сериализаторы на чтение и запись
Самый честный ответ на конфликт «читаем развёрнуто, пишем по ID» — два сериализатора и выбор по методу.
class OrderReadSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ["id", "created_at", "items"]
class OrderWriteSerializer(serializers.ModelSerializer):
class Meta:
model = Order
fields = ["id", "created_at"]
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.prefetch_related("items__product")
def get_serializer_class(self):
if self.action in ("list", "retrieve"):
return OrderReadSerializer
return OrderWriteSerializerВыигрыш: каждая операция описана явно, ничего не подстраивается «магически». Хорошо ложится на разные схемы в OpenAPI (drf-spectacular умеет разбирать это через @extend_schema). Цена: два класса вместо одного, надо помнить про синхронность полей.
Подход 4. Writable nested — запись вложенных объектов одним POST
Когда бизнес-требование именно такое — создать заказ с позициями за один запрос — придётся написать create() и update() руками. DRF из коробки этого не делает намеренно.
class OrderWritableSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
class Meta:
model = Order
fields = ["id", "created_at", "items"]
def create(self, validated_data):
items_data = validated_data.pop("items")
order = Order.objects.create(**validated_data)
# bulk_create вместо цикла с save() — один INSERT на все позиции
OrderItem.objects.bulk_create([
OrderItem(order=order, **item) for item in items_data
])
return orderОберните запись в транзакцию, иначе при падении на позициях останется осиротевший заказ:
from django.db import transaction
class OrderViewSet(viewsets.ModelViewSet):
queryset = Order.objects.prefetch_related("items__product")
serializer_class = OrderWritableSerializer
@transaction.atomic
def perform_create(self, serializer):
serializer.save()С update() сложнее: DRF не знает, что делать с элементами вложенного списка — обновить, удалить отсутствующие или добавить новые. Эту логику пишете сами, и именно здесь появляются баги «PATCH затёр все позиции». Если такой сценарий нужен часто, посмотрите пакет drf-writable-nested — он берёт разбор списков на себя, но добавляет неявную магию, которую придётся объяснять новым людям в команде.
Writable nested — не фича, которую забыли включить, а сознательный отказ авторов DRF: у вложенной записи слишком много неоднозначных случаев, чтобы решать их за вас по умолчанию.
Сравнение
| Подход | Чтение | Запись | Ответ | Риск |
|---|---|---|---|---|
| Nested read-only | развёрнуто | руками | тяжёлый | N+1 без prefetch |
| PrimaryKeyRelatedField | только ID | из коробки | лёгкий | клиенту нужен доп. запрос |
| Read/Write раздельно | развёрнуто | по ID | гибкий | дублирование полей |
| Writable nested | развёрнуто | вложенно | тяжёлый | сложный update, транзакции |
Когда что выбирать
- Read-only API, фронту нужны данные целиком — подход 1 с обязательным
prefetch_related. - Фронт уже держит справочники, ответ должен быть лёгким — подход 2.
- Есть и чтение, и запись, формы разные — подход 3, это дефолт для большинства CRUD-эндпоинтов.
- Бизнес требует создать составной объект одним запросом — подход 4, с транзакцией и осознанным update.
Что ломается чаще всего
- N+1 на
many=True.select_relatedдля FK и OneToOne,prefetch_relatedдля обратных и M2M. Проверяйте черезdjango-debug-toolbarилиassertNumQueriesв тестах. - Забыли
querysetу writable RelatedField — получитеCannot call PrimaryKeyRelatedField without queryset. - PATCH затирает вложенный список — при частичном обновлении в подходе 4 нужно решить, что делать с непереданными элементами, а не молча удалять.
- Осиротевшие связи — вложенная запись без
transaction.atomicоставляет мусор при ошибке на середине.
Что делать дальше
Начните с подхода 3 как базового для CRUD и добавьте assertNumQueries в тесты списков — это ловит N+1 раньше прода. Замеряйте число SQL-запросов на реальном объёме данных, а не на трёх строках фикстур: разница видна только на нагрузке. Если генерируете OpenAPI-схему, сверьте, что drf-spectacular правильно разложил read/write-сериализаторы по методам.
Контент-конвейер: Из одной идеи — текст, изображение, озвучка и ролик. Один заказ вместо четырёх.
Спросить за 60 ₽Источники: Django REST Framework — Serializer relations, Django REST Framework — Serializers (nested)
Частые вопросы
Почему вложенный сериализатор не пишет данные из коробки?
У меня другая версия DRF, код будет работать?
Как понять, что у меня N+1, а не просто медленный запрос?
Стоит ли брать drf-writable-nested вместо ручного create?
Что выбрать, если ответ и так тяжёлый, а фронту нужны все данные?
Можно ли смешать: часть полей ID, часть развёрнуто?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.