Вложенные сериализаторы в DRF: рабочие приёмы из практики
Вложенные сериализаторы в Django REST Framework легко превращают один запрос в сотни SQL и ломают запись. Разбираем, как читать без N+1, писать с create/update и не тащить лишнее на список.

Вложенный сериализатор в Django REST Framework — это когда внутри одного ответа лежит другой объект: заказ с позициями, автор со списком статей, пользователь с профилем. Пишется он в две строки, а потом на списке из 200 заказов вы получаете 201 запрос к базе и таймаут на эндпоинте, который вчера отвечал за 40 мс. И отдельная боль — запись: DRF по умолчанию не умеет создавать и обновлять вложенные объекты, и POST с вложенными данными молча падает или игнорирует часть полей.
Разбираем четыре ситуации, в которые упираются на реальных проектах, и цену каждого решения. Версия — DRF 3.14/3.15 на Django 4.2–5.x. Инвалидацию кеша и пагинацию здесь не трогаем — только сериализаторы.
Задача и почему очевидное решение бьёт по базе
Возьмём две модели: заказ и его позиции.
# models.py
from django.db import models
class Order(models.Model):
number = models.CharField(max_length=32)
created_at = models.DateTimeField(auto_now_add=True)
class OrderItem(models.Model):
order = models.ForeignKey(
Order, related_name="items", on_delete=models.CASCADE
)
title = models.CharField(max_length=255)
price = models.DecimalField(max_digits=10, decimal_places=2)
quantity = models.PositiveIntegerField(default=1)Очевидный сериализатор выглядит так:
# serializers.py
from rest_framework import serializers
class OrderItemSerializer(serializers.ModelSerializer):
class Meta:
model = OrderItem
fields = ["id", "title", "price", "quantity"]
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True, read_only=True)
class Meta:
model = Order
fields = ["id", "number", "created_at", "items"]На чтении одного заказа всё хорошо. Проблема вылезает на списке: для каждого объекта Order DRF отдельно дёргает order.items.all(). 200 заказов — 200 дополнительных запросов плюс один основной. Это классический N+1, и сериализатор тут ни при чём: он честно сериализует то, что ему дали. Дали ленивый queryset без предзагрузки — получите запрос на каждую итерацию.
Приём 1: гасим N+1 через prefetch_related
Связь «один-ко-многим» (items — обратный ForeignKey) лечится prefetch_related на уровне вьюхи, а не сериализатора:
# views.py
from rest_framework import viewsets
class OrderViewSet(viewsets.ModelViewSet):
serializer_class = OrderSerializer
def get_queryset(self):
return Order.objects.prefetch_related("items").all()Теперь запросов ровно два: один за заказами, один за всеми их позициями. Для связей «многие-к-одному» и «один-к-одному» (прямой ForeignKey, OneToOneField) используйте select_related — он делает JOIN и обходится одним запросом.
Правило, которое экономит часы дебага: каждый вложенный сериализатор на списочном эндпоинте обязан иметь пару вprefetch_relatedилиselect_related. Нет пары — есть N+1. Проверяется это не глазами, а счётчиком запросов.
Как убедиться, что помогло — не догадками, а замером. В тесте:
from django.test.utils import CaptureQueriesContext
from django.db import connection
with CaptureQueriesContext(connection) as ctx:
response = client.get("/api/orders/")
print(len(ctx.captured_queries)) # должно быть 2, а не 201В разработке удобнее django-debug-toolbar: он покажет число запросов и дубли прямо на странице.
Приём 2: разные сериализаторы на список и деталь
На списке заказов вложенные позиции обычно не нужны — они раздувают ответ и всё равно тянут вторую таблицу. Отдавайте вложенность только в детальном представлении, переопределив get_serializer_class:
class OrderListSerializer(serializers.ModelSerializer):
items_count = serializers.IntegerField(read_only=True)
class Meta:
model = Order
fields = ["id", "number", "created_at", "items_count"]
class OrderViewSet(viewsets.ModelViewSet):
def get_serializer_class(self):
if self.action == "list":
return OrderListSerializer
return OrderSerializer
def get_queryset(self):
from django.db.models import Count
qs = Order.objects.all()
if self.action == "list":
return qs.annotate(items_count=Count("items"))
return qs.prefetch_related("items")На списке отдаём число позиций одним Count в annotate, на детали — полный вложенный объект с prefetch_related. Клиент, которому нужны позиции, идёт за конкретным заказом.
Приём 3: запись вложенных объектов
Уберите read_only=True — и POST с вложенным items пройдёт валидацию, но ModelSerializer не создаст позиции: стандартный create() не знает, что делать с вложенным списком. Логику записи придётся написать руками.
class OrderSerializer(serializers.ModelSerializer):
items = OrderItemSerializer(many=True)
class Meta:
model = Order
fields = ["id", "number", "created_at", "items"]
def create(self, validated_data):
items_data = validated_data.pop("items")
order = Order.objects.create(**validated_data)
OrderItem.objects.bulk_create([
OrderItem(order=order, **item) for item in items_data
])
return orderbulk_create вставляет все позиции одним запросом вместо цикла с save(). Обновление сложнее: приходит список, где часть позиций новые, часть меняется, часть удалена. Наивный вариант — снести все и создать заново — ломает id позиций и рвёт связи, если на них кто-то ссылается. Разбор по id:
def update(self, instance, validated_data):
items_data = validated_data.pop("items", None)
instance.number = validated_data.get("number", instance.number)
instance.save()
if items_data is None:
return instance
# что пришло с id — обновляем, без id — создаём,
# чего в запросе нет — удаляем
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 field, value in item.items():
setattr(obj, field, value)
obj.save()
sent_ids.add(item_id)
else:
OrderItem.objects.create(order=instance, **item)
to_delete = set(existing) - sent_ids
instance.items.filter(id__in=to_delete).delete()
return instanceЧтобы id доходил до validated_data, а не отбрасывался как read-only, объявите его во вложенном сериализаторе явно: id = serializers.IntegerField(required=False). Всю операцию оборачивайте в транзакцию, иначе при падении на третьей позиции заказ останется в битом состоянии:
from django.db import transaction
@transaction.atomic
def update(self, instance, validated_data):
...Приём 4: писать id, читать объект
Частый компромисс — на запись принимать только идентификатор связанного объекта, а на чтение отдавать его целиком. Для этого держите два поля: PrimaryKeyRelatedField для записи и вложенный сериализатор для чтения.
class OrderSerializer(serializers.ModelSerializer):
customer = CustomerSerializer(read_only=True)
customer_id = serializers.PrimaryKeyRelatedField(
queryset=Customer.objects.all(),
source="customer",
write_only=True,
)
class Meta:
model = Order
fields = ["id", "number", "customer", "customer_id"]Клиент шлёт {"customer_id": 5}, а в ответе получает вложенный объект customer. Это работает для связи через ForeignKey и снимает необходимость городить create() вручную ради одной ссылки.
Когда что выбирать
| Приём | Когда применять | Цена |
|---|---|---|
| prefetch/select_related | Всегда, где есть вложенность на списке | Строчка во вьюхе; надо не забыть |
| Разные сериализаторы список/деталь | Вложенные данные тяжёлые и нужны не всем | Два класса вместо одного |
| Ручные create/update | Клиент шлёт вложенный объект на запись | Код, транзакции, тесты на удаление |
| id на запись, объект на чтение | Связь через FK, вложенный объект не создаётся | Дублирующее поле в fields |
Что ломается на практике
- Незамеченный N+1.
prefetch_relatedсбрасывается, если во вьюхе или сериализаторе делать.filter()по уже предзагруженной связи — Django заново идёт в базу. Фильтруйте черезPrefetchс указанием queryset. - Валидация вложенного списка. Ошибки во вложенном сериализаторе приходят массивом по индексам:
{"items": [{}, {"price": ["..."]}]}. Фронт должен уметь это разбирать, иначе пользователь не поймёт, какая позиция битая. - Запись без транзакции. Падение на середине списка позиций оставит заказ с половиной данных. Всегда
@transaction.atomic. - Глубокая вложенность. Три уровня и глубже (
заказ → позиции → скидки) на списке — почти гарантированный тормоз даже с prefetch. Пересмотрите, нужна ли вся глубина в одном ответе, или это отдельный эндпоинт.
Что делать дальше
Поставьте django-debug-toolbar в dev-окружение и посмотрите число SQL-запросов на каждом списочном эндпоинте с вложенностью — это находит N+1 за минуту. На запись напишите тесты, которые отдельно проверяют создание, обновление существующей позиции, добавление новой и удаление пропавшей: именно на удалении чаще всего вылезают баги. Если вложенная запись становится сложнее двух уровней, посмотрите библиотеку drf-writable-nested — она берёт на себя рекурсивный create/update, но её поведение с удалением стоит проверить на своих данных прежде, чем ставить в прод.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django REST Framework — Serializers (writable nested representations), Django — prefetch_related и select_related
Частые вопросы
prefetch_related убирает N+1, но запросов всё равно много — почему?
У меня DRF другой версии — код будет работать?
POST с вложенными данными проходит, но позиции не создаются — в чём дело?
А если запрос на обновление упадёт на середине списка позиций?
Стоит ли использовать drf-writable-nested вместо ручного кода?
Как правильно отдавать связанный объект на чтение, но принимать только его id на запись?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.