N+1 в Django ORM: select_related и prefetch_related
Как найти и устранить N+1 запросы в Django: debug toolbar, assertNumQueries, select_related, prefetch_related, Prefetch, annotate, only, шаблоны, DRF и админка.
Содержание · 15 разделов
- Модели и данные для замеров
- Как обнаружить N+1
- Django Debug Toolbar
- connection.queries и CaptureQueriesContext
- assertNumQueries в тестах
- Логирование django.db.backends
- select_related: ForeignKey и OneToOne
- prefetch_related: обратные связи и ManyToMany
- Ловушка: filter() и first() после prefetch_related
- Prefetch(): queryset и to_attr
- annotate вместо подсчёта в цикле
- only, defer и values
- N+1 в шаблонах
- N+1 в сериализаторах DRF
- N+1 в Django Admin
- Режимы загрузки в Django 6.1: fetch_mode
- Сводная таблица замеров
- Частые ошибки
- Частые вопросы
- Можно ли включить select_related для модели по умолчанию?
- Работает ли prefetch_related с пагинацией?
- Как подгрузить связи для уже полученного списка объектов?
- Как найти N+1 на продакшне, где нет Debug Toolbar?
- Как проверить на N+1 целую страницу, а не один QuerySet?
- Итог
Проблема N+1 — это ситуация, когда код делает один запрос за списком объектов и ещё по запросу на каждый объект, чтобы получить связанные данные. Для 20 книг это 21 запрос вместо одного или двух. В Django N+1 почти всегда возникает незаметно: обращение book.author.name в цикле или шаблоне выглядит как чтение атрибута, но выполняет SQL.
Короткий ответ: для ForeignKey и OneToOneField добавьте в QuerySet select_related("поле") (один запрос с JOIN). Для обратных связей и ManyToManyField — prefetch_related("поле") (второй запрос с IN). Для подсчётов используйте annotate(Count(...)). Ниже — как находить N+1 и как их устранять, с реальным числом запросов из тестового проекта (Django 5.2 и 6.1, SQLite).
Модели и данные для замеров
# catalog/models.py
from django.db import models
class Publisher(models.Model):
name = models.CharField(max_length=200)
class Author(models.Model):
name = models.CharField(max_length=200)
bio = models.TextField(blank=True)
class Tag(models.Model):
name = models.CharField(max_length=50)
class Book(models.Model):
title = models.CharField(max_length=300)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
publisher = models.ForeignKey(
Publisher, on_delete=models.SET_NULL, null=True, blank=True, related_name="books"
)
tags = models.ManyToManyField(Tag, blank=True, related_name="books")
published = models.DateField()
is_published = models.BooleanField(default=True)
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
rating = models.PositiveSmallIntegerField()
В базе 10 авторов, по 2 книги у каждого (20 книг), 5 издательств, у каждой книги 2 тега и 2 отзыва. Как устроены поля и связи — в статье о моделях Django.
Как обнаружить N+1
Django Debug Toolbar
Панель в браузере показывает все SQL-запросы страницы, их время и пометки «similar» и «duplicated» — верный признак N+1.
python -m pip install django-debug-toolbar
# settings.py (только для разработки, DEBUG = True)
INSTALLED_APPS += ["debug_toolbar"]
MIDDLEWARE.insert(0, "debug_toolbar.middleware.DebugToolbarMiddleware")
INTERNAL_IPS = ["127.0.0.1"]
# config/urls.py
from debug_toolbar.toolbar import debug_toolbar_urls
urlpatterns = [
# ... ваши маршруты
] + debug_toolbar_urls()
Функция debug_toolbar_urls() есть в актуальных версиях пакета; в старых документация предлагала path("__debug__/", include("debug_toolbar.urls")).
connection.queries и CaptureQueriesContext
При DEBUG = True Django складывает выполненные запросы в connection.queries:
from django.db import connection, reset_queries
reset_queries()
for book in Book.objects.all()[:5]:
book.author.name
print(len(connection.queries)) # → 6
print(connection.queries[1]["sql"])
# → SELECT ... FROM "catalog_author" WHERE "catalog_author"."id" = 1 LIMIT 21
CaptureQueriesContext работает и при DEBUG = False — им сделаны все замеры в статье:
from django.db import connection
from django.test.utils import CaptureQueriesContext
with CaptureQueriesContext(connection) as ctx:
names = [book.author.name for book in Book.objects.all()]
print(len(ctx.captured_queries)) # → 21
assertNumQueries в тестах
Лучшая защита от регрессий — тест, который падает, если запросов стало больше:
from django.test import TestCase
class BookListTests(TestCase):
def test_no_n_plus_one(self):
with self.assertNumQueries(1):
[b.author.name for b in Book.objects.select_related("author")]
При ошибке Django выводит список запросов: AssertionError: 4 != 1 : 4 queries executed, 1 expected и затем SQL каждого запроса.
Логирование django.db.backends
Чтобы видеть SQL прямо в консоли runserver, включите логгер (работает только при DEBUG = True):
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {"console": {"class": "logging.StreamHandler"}},
"loggers": {
"django.db.backends": {"handlers": ["console"], "level": "DEBUG"},
},
}
select_related: ForeignKey и OneToOne
books = Book.objects.all()
[b.author.name for b in books] # → 21 запрос
books = Book.objects.select_related("author")
[b.author.name for b in books] # → 1 запрос
select_related добавляет JOIN и забирает поля связанной модели тем же запросом. Для обязательного внешнего ключа — INNER JOIN, для null=True — LEFT OUTER JOIN:
SELECT "catalog_book"."id", ..., "catalog_author"."name", ..., "catalog_publisher"."name"
FROM "catalog_book"
INNER JOIN "catalog_author" ON ("catalog_book"."author_id" = "catalog_author"."id")
LEFT OUTER JOIN "catalog_publisher" ON ("catalog_book"."publisher_id" = "catalog_publisher"."id")
Можно идти по цепочке: select_related("author", "publisher") или select_related("book__author") из модели Review. Если нужен только ID связанного объекта, читайте book.author_id — это поле самой книги, запроса не будет.
Важно:
select_related()без аргументов подтягивает только внешние ключи сnull=False. В Django 6.1 такой вызов объявлен устаревшим (RemovedInDjango2028Warning) — перечисляйте поля явно.
prefetch_related: обратные связи и ManyToMany
JOIN для связи «один ко многим» размножил бы строки, поэтому prefetch_related делает отдельный запрос с IN и раскладывает результаты по объектам в Python:
authors = Author.objects.all()
[[b.title for b in a.books.all()] for a in authors] # → 11 запросов
authors = Author.objects.prefetch_related("books")
[[b.title for b in a.books.all()] for a in authors] # → 2 запроса
SELECT ... FROM "catalog_author";
SELECT ... FROM "catalog_book" WHERE "catalog_book"."author_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Для ManyToManyField всё так же: 20 книг с тегами — 21 запрос без оптимизации и 2 с prefetch_related("tags").
Методы комбинируются, а связи можно указывать через двойное подчёркивание:
Book.objects.select_related("author").prefetch_related("tags") # → 2 запроса
Author.objects.prefetch_related("books__tags") # → 3 запроса
| Связь | Метод | Запросов |
|---|---|---|
ForeignKey, OneToOneField (прямая) | select_related | 1 (JOIN) |
обратная ForeignKey (author.books) | prefetch_related | +1 на связь |
ManyToManyField (в обе стороны) | prefetch_related | +1 на связь |
GenericForeignKey | prefetch_related | +1 на тип |
Ловушка: filter() и first() после prefetch_related
Кеш prefetch работает только для .all(). Любой новый запрос к менеджеру связи его игнорирует:
authors = Author.objects.prefetch_related("books")
[list(a.books.filter(is_published=True)) for a in authors] # → 12 запросов
[a.books.first() for a in authors] # → 12 запросов
[a.books.all()[0] for a in authors] # → 2 запроса
[len(a.books.all()) for a in authors] # → 2 запроса
Prefetch выполнился впустую, и к нему добавились 10 запросов. Фильтровать нужно на этапе загрузки — через Prefetch.
Prefetch(): queryset и to_attr
Объект Prefetch задаёт, какой QuerySet использовать для связанных объектов, и позволяет положить результат в отдельный атрибут:
from django.db.models import Prefetch
authors = Author.objects.prefetch_related(
Prefetch(
"books",
queryset=Book.objects.filter(is_published=True)
.select_related("publisher")
.order_by("-published"),
to_attr="published_books",
)
)
for author in authors:
for book in author.published_books: # обычный list
print(book.title, book.publisher.name)
# → 2 запроса
queryset— фильтр, сортировка и дажеselect_relatedдля вложенных связей;to_attr— имя атрибута со списком объектов. Безto_attrотфильтрованный результат заменит кешauthor.books.all(), и это легко забыть в другом месте кода.
annotate вместо подсчёта в цикле
[a.books.count() for a in Author.objects.all()] # → 11 запросов
from django.db.models import Count
authors = Author.objects.annotate(books_count=Count("books"))
[a.books_count for a in authors] # → 1 запрос (LEFT JOIN + GROUP BY)
Если связи уже загружены prefetch_related, a.books.count() и len(a.books.all()) берут данные из кеша. Но заводить prefetch ради одного числа незачем — annotate дешевле.
Важно: несколько агрегатов по разным связям в одном
annotateумножают строки JOIN. В нашем примереannotate(n=Count("books"), avg=Avg("books__reviews__rating"))вернулn = 4вместо 2, потому что у каждой книги по 2 отзыва. ИспользуйтеCount("books", distinct=True)или подзапросы (Subquery).
Больше об агрегатах — в шпаргалке по Django ORM.
only, defer и values
only() и defer() уменьшают объём данных, но могут создать N+1 на полях:
[(b.title, b.published) for b in Book.objects.only("title")] # → 21 запрос
[a.bio for a in Author.objects.defer("bio")] # → 11 запросов
Обращение к отложенному полю загружает его отдельным запросом для каждого объекта. Указывайте в only() все поля, которые прочитаете, в том числе через связь: select_related("author").only("title", "author__name") — 1 запрос.
Если объекты моделей не нужны (выгрузка, JSON, отчёт), values() сразу вернёт словари одним запросом:
Book.objects.values("title", "author__name") # → 1 запрос, список словарей
N+1 в шаблонах
Шаблон не выполняет SQL явно, но каждое {{ book.author.name }} и {% for tag in book.tags.all %} — это доступ к связи:
{% for book in books %}
{{ book.title }} — {{ book.author.name }}
{% for tag in book.tags.all %}{{ tag.name }}{% endfor %}
{% endfor %}
С books = Book.objects.all() этот фрагмент выполнил 41 запрос (1 + 20 авторов + 20 наборов тегов). С Book.objects.select_related("author").prefetch_related("tags") — 2. Оптимизация всегда делается во view, где формируется QuerySet: шаблон не должен вызывать методы, которые строят новые запросы (.filter, .count на непредзагруженных связях).
N+1 в сериализаторах DRF
Сериализатор с полем по связи и вложенным списком ведёт себя как шаблон:
class BookSerializer(serializers.ModelSerializer):
author_name = serializers.CharField(source="author.name")
tags = TagSerializer(many=True)
class Meta:
model = Book
fields = ["id", "title", "author_name", "tags"]
BookSerializer(Book.objects.all(), many=True).data — 41 запрос; с select_related("author").prefetch_related("tags") — 2. Оптимизируйте queryset во ViewSet или в get_queryset(). Подробнее — в руководстве по Django REST Framework и статье о сериализаторах DRF.
N+1 в Django Admin
Если внешний ключ указан прямо в list_display, админка сама добавляет select_related. Но есть нюанс версий: в Django 5.2 и 6.0 она вызывает select_related() без аргументов, и поле publisher с null=True не подтягивается — в нашем замере список книг дал 21 запрос. В Django 6.1 админка передаёт в select_related() внешние ключи из list_display по именам, и тот же список выполняется одним запросом.
Методы в list_display, которые обращаются к связям, админка не анализирует:
@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
list_display = ["title", "author", "publisher_name"]
list_select_related = ["author", "publisher"] # без этой строки — 21 запрос, с ней — 1
@admin.display(description="Издательство")
def publisher_name(self, obj):
return obj.publisher.name if obj.publisher else "—"
Подсчёты в колонках переносите в get_queryset() с annotate — колонка obj.books.count() давала 11 запросов на 10 авторов, аннотация — 1. Готовый пример — в руководстве по Django Admin.
Режимы загрузки в Django 6.1: fetch_mode
В Django 6.1 у QuerySet появился метод fetch_mode() (в 5.2 и 6.0 его нет):
from django.db.models import FETCH_PEERS, FETCH_RAISE
[b.author.name for b in Book.objects.fetch_mode(FETCH_PEERS)] # → 2 запроса
[b.author.name for b in Book.objects.fetch_mode(FETCH_RAISE)]
# → django.core.exceptions.FieldFetchBlocked: Fetching of Book.author blocked.
FETCH_PEERS при первом обращении к связи загружает её сразу для всех объектов из той же выборки. FETCH_RAISE запрещает ленивую загрузку — удобно в тестах, чтобы находить пропущенный select_related. Режимы действуют на ForeignKey, OneToOneField (в обе стороны), generic-связи и поля, отложенные через only()/defer(): сценарий only("title") с FETCH_PEERS тоже дал 2 запроса вместо 21. На менеджеры связей режимы не влияют — обход author.books.all() остался на 11 запросах, для обратных связей и ManyToMany по-прежнему нужен prefetch_related.
Сводная таблица замеров
| Сценарий (10 авторов, 20 книг) | Без оптимизации | С оптимизацией |
|---|---|---|
book.author.name в цикле | 21 | 1 |
author.books.all() в цикле | 11 | 2 |
book.tags.all() в цикле | 21 | 2 |
author.books.count() в цикле | 11 | 1 (annotate) |
only("title") и чтение published | 21 | 1 |
| шаблон: автор + теги | 41 | 2 |
| сериализатор DRF: автор + теги | 41 | 2 |
админка: метод с obj.publisher | 21 | 1 |
Частые ошибки
- Оптимизация не в том месте.
select_relatedдобавили в одну view, а шаблон получает QuerySet из другой. Проверяйте страницу целиком через Debug Toolbar. select_relatedдля обратной связи или ManyToMany. Django выдастFieldError: Invalid field name(s) given in select_related. Для таких связей нуженprefetch_related.- Фильтр после prefetch.
a.books.filter(...)иa.books.first()не используют кеш — применяйтеPrefetch(..., to_attr=...). - Огромный JOIN.
select_relatedпо пяти связям с большими текстовыми полями тянет лишние данные. Комбинируйте сonly()или выносите редкие данные на отдельную страницу. - Оптимизация вслепую. Сначала измерьте число запросов, потом исправляйте и закрепляйте результат тестом с
assertNumQueries.
Частые вопросы
Можно ли включить select_related для модели по умолчанию?
Да, через свой менеджер: переопределите get_queryset(), и все запросы через objects получат JOIN. В Django 6.1 так же задают режим загрузки по умолчанию — .fetch_mode(FETCH_PEERS).
class BookManager(models.Manager):
def get_queryset(self):
return super().get_queryset().select_related("author")
class Book(models.Model):
# ...
objects = BookManager()
JOIN будет добавляться и там, где автор не нужен, поэтому для тяжёлых связей надёжнее указывать select_related в конкретных view.
Работает ли prefetch_related с пагинацией?
Да. Paginator применяет к QuerySet срез, и второй запрос получает только ID объектов текущей страницы:
page = Paginator(Author.objects.order_by("name").prefetch_related("books"), 5).page(2)
# SELECT ... FROM "catalog_author" ORDER BY "catalog_author"."name" ASC LIMIT 5 OFFSET 5
# SELECT ... FROM "catalog_book" WHERE "catalog_book"."author_id" IN (6, 7, 8, 9, 10)
Плюс один запрос COUNT(*) на число страниц.
Как подгрузить связи для уже полученного списка объектов?
Если объекты уже загружены (из кеша, из другой функции), вызовите prefetch_related_objects() — он работает со списком, а не с QuerySet:
from django.db.models import prefetch_related_objects
authors = list(Author.objects.all())
prefetch_related_objects(authors, "books")
[[b.title for b in a.books.all()] for a in authors] # → 2 запроса всего
Как найти N+1 на продакшне, где нет Debug Toolbar?
Логируйте число запросов на запрос пользователя через свой middleware с CaptureQueriesContext или connection.execute_wrapper(), либо используйте APM: Sentry выделяет повторяющиеся похожие запросы в отдельный тип проблем производительности. Найденный сценарий воспроизведите локально и закрепите тестом с assertNumQueries.
Как проверить на N+1 целую страницу, а не один QuerySet?
Оберните в assertNumQueries запрос тестового клиента. Надёжный признак отсутствия N+1 — число запросов не зависит от числа объектов: добавьте данные и убедитесь, что оно не выросло.
class BookListPageTests(TestCase):
def test_queries_do_not_grow(self):
create_books(5)
with self.assertNumQueries(2):
self.client.get("/books/")
create_books(20)
with self.assertNumQueries(2):
self.client.get("/books/")
Здесь create_books() — ваша фабрика тестовых данных. Если страница требует входа или использует сессии, число запросов будет больше — зафиксируйте фактическое значение после оптимизации.
Итог
N+1 появляется везде, где код обходит список объектов и обращается к связям: во view, шаблонах, сериализаторах и админке. Находите его через Debug Toolbar и CaptureQueriesContext, устраняйте select_related для прямых связей, prefetch_related и Prefetch — для обратных и ManyToMany, annotate — для подсчётов, и фиксируйте результат с помощью assertNumQueries.
Дальше: все методы QuerySet — в шпаргалке по Django ORM, раздел — в хабе Django ORM, модели и базы данных. Справочник — в документации Django по оптимизации работы с БД.
- #Django ORM
- #N+1
- #select_related
- #prefetch_related
- #Оптимизация