Django-Admin.ru

N+1 в Django ORM: select_related и prefetch_related

Как найти и устранить N+1 запросы в Django: debug toolbar, assertNumQueries, select_related, prefetch_related, Prefetch, annotate, only, шаблоны, DRF и админка.

Редакция Django-Admin.ru
· обновлено · 12 мин чтения

Содержание · 15 разделов
  1. Модели и данные для замеров
  2. Как обнаружить N+1
  3. Django Debug Toolbar
  4. connection.queries и CaptureQueriesContext
  5. assertNumQueries в тестах
  6. Логирование django.db.backends
  7. select_related: ForeignKey и OneToOne
  8. prefetch_related: обратные связи и ManyToMany
  9. Ловушка: filter() и first() после prefetch_related
  10. Prefetch(): queryset и to_attr
  11. annotate вместо подсчёта в цикле
  12. only, defer и values
  13. N+1 в шаблонах
  14. N+1 в сериализаторах DRF
  15. N+1 в Django Admin
  16. Режимы загрузки в Django 6.1: fetch_mode
  17. Сводная таблица замеров
  18. Частые ошибки
  19. Частые вопросы
  20. Можно ли включить select_related для модели по умолчанию?
  21. Работает ли prefetch_related с пагинацией?
  22. Как подгрузить связи для уже полученного списка объектов?
  23. Как найти N+1 на продакшне, где нет Debug Toolbar?
  24. Как проверить на N+1 целую страницу, а не один QuerySet?
  25. Итог

Проблема 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"},
    },
}
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) — перечисляйте поля явно.

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_related1 (JOIN)
обратная ForeignKey (author.books)prefetch_related+1 на связь
ManyToManyField (в обе стороны)prefetch_related+1 на связь
GenericForeignKeyprefetch_related+1 на тип

Кеш 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 в цикле211
author.books.all() в цикле112
book.tags.all() в цикле212
author.books.count() в цикле111 (annotate)
only("title") и чтение published211
шаблон: автор + теги412
сериализатор DRF: автор + теги412
админка: метод с obj.publisher211

Частые ошибки

  • Оптимизация не в том месте. 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.

Частые вопросы

Да, через свой менеджер: переопределите 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.

Да. 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
  • #Оптимизация
↑ ↓ выборEnter открытьEsc закрыть