select_related и prefetch_related в Django: N+1 под нагрузкой
Страница списка заказов начала отдавать 300+ SQL-запросов на один HTTP-запрос. Разбираем, чем select_related отличается от prefetch_related, где каждый ломается и что мерить.

Список из 50 заказов на странице администратора генерировал 214 SQL-запросов и отдавался за 1,3 секунды при пустой базе на локальной машине. В продакшене под нагрузкой в несколько сотен параллельных запросов это превращалось в блокировки пула соединений и таймауты. Причина классическая — проблема N+1: ORM тихо ходит в базу в цикле шаблона, и вы этого не видите, пока не откроете лог запросов. Разберём, чем select_related отличается от prefetch_related, когда каждый из них экономит запросы, а когда, наоборот, съедает память и время. Примеры проверены на Django 5.x и PostgreSQL 16, но механика та же начиная с Django 1.x.
Откуда берётся N+1
Django ORM ленив: QuerySet не ходит в базу, пока вы не начнёте итерировать результат или обращаться к связанным объектам. Возьмём модели.
# models.py
from django.db import models
class Customer(models.Model):
name = models.CharField(max_length=200)
class Order(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.CASCADE)
total = models.DecimalField(max_digits=10, decimal_places=2)
class Item(models.Model):
order = models.ForeignKey(Order, related_name="items", on_delete=models.CASCADE)
title = models.CharField(max_length=200)
Теперь наивный код в представлении или шаблоне:
# 1 запрос на сам список заказов
orders = Order.objects.all()
for order in orders:
# ещё 1 запрос на КАЖДОМ обращении к customer
print(order.customer.name)
На 50 заказов это 1 запрос на список плюс 50 запросов на клиентов — те самые N+1. Каждое обращение к order.customer — отдельный round-trip до базы. Обращение к order.items.all() в цикле даёт то же самое.
Как увидеть проблему, а не угадывать
Не оптимизируйте вслепую. Сначала посчитайте запросы. В тестах есть готовый ассерт:
from django.test import TestCase
class OrderQueryTest(TestCase):
def test_list_queries(self):
# ожидаем ровно 2 запроса, а не N+1
with self.assertNumQueries(2):
list(Order.objects.select_related("customer"))
В интерактивной отладке смотрите django.db.connection.queries (работает только при DEBUG = True) или поставьте django-debug-toolbar — он покажет число запросов и дубли прямо на странице.
select_related: JOIN на уровне SQL
select_related решает связи, которые ведут к одному объекту: ForeignKey и OneToOneField. Он добавляет JOIN в тот же SQL-запрос и заполняет связанные объекты сразу.
# 1 запрос с INNER/LEFT JOIN по customer
orders = Order.objects.select_related("customer")
for order in orders:
print(order.customer.name) # в базу больше не ходит
Можно идти по цепочке через двойное подчёркивание: select_related("customer__region") добавит второй JOIN. Чем длиннее цепочка и чем шире таблицы, тем толще строка результата — JOIN тянет все колонки связанных таблиц в каждую строку.
Где ломается: select_related не работает для обратных связей и для ManyToManyField. Попытка написать select_related("items") (обратная связь один-ко-многим) вызовет ошибку. Причина простая: один заказ имеет много позиций, JOIN размножит строки заказа — это уже задача prefetch_related.
prefetch_related: отдельный запрос и склейка в Python
prefetch_related делает отдельный запрос на связанные объекты и склеивает их с основными в памяти Python. Работает для ManyToManyField и обратных ForeignKey.
# 2 запроса: один на заказы, один на все items разом (WHERE order_id IN (...))
orders = Order.objects.prefetch_related("items")
for order in orders:
for item in order.items.all(): # берётся из кеша, не из базы
print(item.title)
Ключевой момент: внутри цикла надо вызывать order.items.all() без дополнительных фильтров. Стоит написать order.items.filter(...) — и Django проигнорирует префетч и снова уйдёт в базу на каждой итерации. Для фильтрованного префетча есть Prefetch.
from django.db.models import Prefetch
# префетчим только дорогие позиции и кладём их в отдельный атрибут
orders = Order.objects.prefetch_related(
Prefetch(
"items",
queryset=Item.objects.filter(title__icontains="pro"),
to_attr="pro_items",
)
)
for order in orders:
for item in order.pro_items: # обычный список, не менеджер
print(item.title)
Правило большого пальца: связь «к одному» — select_related, связь «ко многим» — prefetch_related. Их можно и нужно комбинировать в одном QuerySet.
orders = (
Order.objects
.select_related("customer") # JOIN, к одному
.prefetch_related("items") # отдельный запрос, ко многим
)
Чем платит каждый подход
| Критерий | select_related | prefetch_related |
|---|---|---|
| Типы связей | ForeignKey, OneToOne (к одному) | ManyToMany, обратный FK (ко многим) |
| Число запросов | 1 (общий JOIN) | 1 на каждую префетч-связь + основной |
| Где склейка | в базе (SQL JOIN) | в Python по IN-списку |
| Риск дублей строк | есть при цепочке к «многим» | нет |
| Главная цена | широкая строка, тяжёлый JOIN | память под все связанные объекты + IN на много id |
Когда select_related становится дороже проблемы
JOIN нескольких широких таблиц с большим числом колонок раздувает объём передаваемых данных, даже если строк немного. Если из связанной таблицы вам нужны две колонки из тридцати — добавьте .only("customer__name") или .values(...), чтобы не тянуть лишнее.
Когда prefetch_related упирается в память и IN
prefetch_related грузит в память Python все связанные объекты для всей страницы результатов. Если у вас 1000 заказов и у каждого сотни позиций, второй запрос вернёт десятки тысяч строк, и все они окажутся в памяти процесса разом. Плюс список id в WHERE ... IN (...) может стать очень длинным. Лечение — пагинация на уровне QuerySet до применения префетча, а не после.
Что мерить и как проверить эффект
Сравнение до/после на нашем списке заказов (50 заказов, у каждого в среднем 4 позиции, PostgreSQL 16 локально, DEBUG-лог запросов):
- Без оптимизации: 1 + 50 (customer) + 50 (items) = 101 запрос.
- select_related("customer"): 1 + 50 (items) = 51 запрос.
- + prefetch_related("items"): 3 запроса (заказы, клиенты через JOIN уже внутри, позиции отдельным запросом) — фактически 2, если префетч один.
Абсолютные миллисекунды зависят от вашего железа, сети до базы и объёма данных — снимайте их у себя, а не переносите чужие цифры. Ориентир для регрессии — фиксировать число запросов тестом assertNumQueries, чтобы новый шаблон случайно не вернул N+1.
Частые ошибки, которые дают N+1 обратно
- Фильтр внутри цикла:
order.items.filter(...)или.exclude(...)игнорируют префетч. ИспользуйтеPrefetchс готовым queryset. - Срез или
.count()на префетч-связи:order.items.count()сходит в базу; беритеlen(order.items.all()), если объекты и так нужны. - select_related там, где связь ко многим: ошибка на этапе построения запроса.
- Обращение к связи, которую не запросили: добавили новое поле в шаблон — не забыли добавить его в select_related/prefetch_related.
Что делать дальше
Поставьте django-debug-toolbar на dev-окружение и держите панель SQL открытой при разработке страниц со списками — так N+1 виден сразу, а не в проде под нагрузкой. На критичных вьюхах закрепите число запросов тестом assertNumQueries. Когда упрётесь в память при больших префетчах — смотрите в сторону пагинации и Prefetch с ограниченными queryset, а не в увеличение размера пула соединений. Инвалидация и кеширование результатов QuerySet — отдельная тема, здесь мы разбирали только сокращение числа запросов.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django documentation — select_related и prefetch_related (QuerySet API), Django documentation — Database access optimization
Частые вопросы
Можно ли использовать select_related и prefetch_related одновременно?
А если у меня старая версия Django, там всё так же?
Почему после prefetch_related всё равно много запросов?
Что выбрать, если связь ManyToMany?
Как понять, что оптимизация действительно сработала, а не стало хуже?
select_related всегда быстрее, раз это один запрос?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.