Проблема N+1 в Django ORM: как найти и убрать лишние запросы
Один цикл по queryset превращается в сотни запросов к базе. Разбираем, чем select_related отличается от prefetch_related, как поймать N+1 в проде и где эти методы всё равно не спасают.

Страница списка заказов открывалась за 1,8 секунды при сотне записей. В Django Debug Toolbar висело 214 SQL-запросов вместо ожидаемых двух. Причина — обращение к order.customer.name внутри шаблона: на каждый заказ ORM делал отдельный поход в базу. Это и есть проблема N+1: один запрос на выборку списка плюс N запросов на связанные объекты. Ниже — как её ловить и чем лечить в Django 5.x (для 4.2 всё то же самое).
Откуда берётся N+1
Django ORM ленивый: запрос к базе не выполняется, пока вы не обратитесь к данным. Внешние ключи и обратные связи подгружаются в момент доступа к атрибуту. Пока объект один — это удобно. Как только вы идёте по списку и трогаете связанный объект в цикле, каждая итерация порождает свой SELECT.
# Django 5.x — так писать нельзя
orders = Order.objects.all() # 1 запрос: список заказов
for order in orders:
print(order.customer.name) # +1 запрос на КАЖДЫЙ заказ
Сто заказов — сто один запрос. То же самое происходит в шаблоне, если внутри {% for %} вы обращаетесь к order.customer.name или к обратной связи вроде order.items.all. Шаблон коварнее кода: N+1 прячется за версткой, и в питоне вы его глазами не видите.
Правило простое: если в цикле или в шаблонном for вы трогаете связанный объект — почти наверняка у вас N+1. Проверяйте счётчик запросов, а не интуицию.
Два инструмента: select_related и prefetch_related
В ORM есть два метода жадной загрузки. Они решают N+1, но по-разному и не взаимозаменяемы.
select_related — для «один» на конце связи
Работает через SQL JOIN. Подходит для ForeignKey и OneToOneField — там, где связанный объект ровно один. Django подтягивает связанные строки одним запросом.
# customer — ForeignKey у модели Order
orders = Order.objects.select_related('customer')
for order in orders:
print(order.customer.name) # 0 доп. запросов: всё пришло одним JOIN
Можно идти по цепочке через двойное подчёркивание: select_related('customer__region') подтянет и регион покупателя.
prefetch_related — для «многих» на конце связи
Работает не через JOIN, а через отдельный запрос с последующей склейкой в питоне. Нужен для ManyToManyField и обратных ForeignKey (когда у одного объекта много связанных).
# items — обратная связь: у одного заказа много позиций
orders = Order.objects.prefetch_related('items')
for order in orders:
for item in order.items.all(): # 0 доп. запросов
print(item.title)
Здесь Django сделает два запроса: один за заказами, один за всеми позициями сразу, а потом разложит позиции по заказам в памяти. JOIN тут был бы вреден — он размножил бы строки заказов на число позиций.
| Критерий | select_related | prefetch_related |
|---|---|---|
| Тип связи | ForeignKey, OneToOne | ManyToMany, обратный ForeignKey |
| Как работает | SQL JOIN, один запрос | отдельный запрос + склейка в Python |
| Число «многих» на конце | один объект | много объектов |
| Цена | шире строки в выборке | +1 запрос и память под склейку |
Как поймать N+1
Полагаться на глаз бессмысленно — считайте запросы.
- Django Debug Toolbar — панель SQL показывает число запросов на странице и подсвечивает дубли. Устанавливается пакетом
django-debug-toolbar, включается только на dev. - connection.queries — в шелле или тесте выводит список выполненных SQL. Работает при
DEBUG = True. - assertNumQueries — метод тестового класса Django. Оборачивает блок и падает, если запросов больше ожидаемого. Ставьте его на вьюхи со списками — регрессию N+1 поймает автоматически.
- nplusone — сторонний пакет, который логирует или роняет запрос при обнаружении N+1. Полезен, когда связей много и вручную не уследить.
# замер вручную в manage.py shell
from django.db import connection, reset_queries
reset_queries()
list(Order.objects.all()) # список, чтобы queryset выполнился
for o in Order.objects.all():
_ = o.customer.name
print(len(connection.queries)) # число запросов за блок
Тест-страж на вьюху:
def test_order_list_queries(self):
# ожидаем 2 запроса: заказы + prefetch позиций
with self.assertNumQueries(2):
self.client.get('/orders/')
Типичные ошибки
Даже зная про два метода, легко наступить на грабли.
- select_related на «многих». Попытка
select_related('items')для обратного FK вызовет ошибку — используйте prefetch. И наоборот. - Обращение к связи в шаблоне без prefetch во вьюхе. Код чистый, а N+1 живёт в HTML. Всегда проверяйте счётчик на отрендеренной странице, а не только на queryset.
- Новый queryset внутри prefetch. Вызов вроде
order.items.filter(active=True)в цикле сбрасывает префетч и снова бьёт в базу. Чтобы отфильтровать префетч, используйтеPrefetch()с параметромqueryset. - count() и exists() на связанных.
order.items.count()в цикле — тоже отдельный запрос на итерацию. Если данные уже в prefetch, считайте черезlen(order.items.all()). - only()/defer() съедают эффект. Если ограничить поля так, что связанное поле не попало в выборку, доступ к нему снова уйдёт в базу.
from django.db.models import Prefetch
# фильтрованный префетч без нового запроса в цикле
active_items = Item.objects.filter(active=True)
orders = Order.objects.prefetch_related(
Prefetch('items', queryset=active_items, to_attr='active_items')
)
for order in orders:
for item in order.active_items: # список в памяти, 0 запросов
print(item.title)
Где это всё равно не спасает
Жадная загрузка убирает N+1, но не бесплатна.
- Глубокие цепочки select_related раздувают JOIN и ширину строки. На пяти уровнях вложенности один запрос может тянуть больше данных, чем несколько отдельных.
- prefetch по большому списку грузит все связанные объекты в память разом. Если у вас 10 000 заказов по 50 позиций, второй запрос вернёт полмиллиона строк — сервер приложения это почувствует.
- Пагинация против prefetch. Префетч выполняется по срезу queryset, так что применяйте его после пагинации, иначе подтянете связи ко всей таблице.
Для агрегатов вместо prefetch иногда правильнее annotate(Count('items')) — база посчитает на своей стороне одним запросом, без вытягивания строк в питон.
Что делать дальше
Порядок работы простой: включите Debug Toolbar на dev, откройте самые тяжёлые страницы со списками и посмотрите счётчик SQL. Каждую вьюху со списком закройте тестом assertNumQueries — это остановит возврат N+1 при рефакторинге. В проде смотрите на медленные запросы и их количество на запрос через APM или логи базы. Инвалидация и кеширование queryset — отдельная тема, здесь мы говорили только про жадную загрузку.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django docs — select_related and prefetch_related (QuerySet API), Django docs — Database access optimization
Частые вопросы
У меня не Django 5, а 4.2 — приёмы те же?
Я добавил select_related, а запросов меньше не стало — почему?
Что выбрать: select_related или prefetch_related?
Почему count() внутри цикла ломает prefetch?
Не сработало на проде, хотя на dev запросов мало — с чего начать?
Prefetch грузит слишком много строк — что делать?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.