Django ORM прячет SQL за красивым Python-интерфейсом. Пишешь User.objects.filter(active=True).order_by('name')[:10] — получаешь список пользователей. Круто. Но когда запросы тормозят или N+1 пожирает базу, приходится понимать, что вообще происходит.

Разберём внутренности QuerySet: почему он ленивый, как работает chaining, когда запрос реально выполняется, и чем select_related отличается от prefetch_related на уровне SQL.

QuerySet — это не список

Главное заблуждение: User.objects.all() возвращает список пользователей. Нет. Он возвращает QuerySet — объект, описывающий запрос, но не выполняющий его.

qs = User.objects.filter(active=True)
print(type(qs))  # <class 'django.db.models.query.QuerySet'>

# SQL ещё не выполнялся!
print(qs.query)  # SELECT ... FROM users WHERE active = True

QuerySet хранит параметры запроса (фильтры, сортировки, лимиты), но идёт в базу только когда данные реально нужны.

Это называется lazy evaluation. Преимущества в том, чт можно строить запрос по частям, передавать между функциями. Сам же запрос выполняется один раз, даже если QuerySet модифицировался много раз. Еще тут легко оптимизировать — добавлять select_related, only() до выполнения

Когда QuerySet выполняется

QuerySet просыпается и идёт в базу при:

Итерации:

for user in User.objects.all():  # Выполняется здесь
    print(user.name)

Срезах с шагом или negative index:

users = User.objects.all()
list(users[0:10])      # Выполняется (LIMIT 10)
users[0]               # Выполняется (LIMIT 1)
list(users[::2])       # Выполняется (весь результат, потом срез в Python)

len():

count = len(User.objects.all())  # SELECT COUNT(*) — но загружает всё в память!

Для подсчёта используется .count() — это отдельный SELECT COUNT(*).

bool():

if User.objects.filter(active=True):  # SELECT ... LIMIT 1
    print("есть активные")

Лучше использовать .exists() — он оптимизирован.

list(), repr():

users = list(User.objects.all())  # Выполняется
print(User.objects.all())          # Выполняется (для repr)

Оценка (evaluation) в шаблонах:

{% for user in users %}  {# Если users — QuerySet, выполняется здесь #}

Chaining

Методы QuerySet возвращают новый QuerySet:

qs1 = User.objects.all()
qs2 = qs1.filter(active=True)
qs3 = qs2.order_by('name')

# qs1, qs2, qs3 — разные объекты
print(qs1 is qs2)  # False

Каждый метод клонирует QuerySet и добавляет своё условие. Оригинал не меняется.

Внутри QuerySet хранит объект Query, который накапливает параметры:

qs = User.objects.filter(active=True).exclude(role='admin').order_by('-created')

# Посмотрим на внутренности
print(qs.query.where)      # Дерево условий WHERE
print(qs.query.order_by)   # ['created DESC']

При выполнении Query компилируется в SQL через бекенд баз данных. Разные базы — разный SQL.

Кэширование результатов

После выполнения QuerySet кэширует результат:

qs = User.objects.all()

# Первая итерация — запрос к базе
for user in qs:
    print(user.name)

# Вторая итерация — из кэша, без запроса
for user in qs:
    print(user.email)

Но кэш работает только если QuerySet полностью оценён. Срезы не кэшируются:

qs = User.objects.all()

print(qs[0])  # Запрос: LIMIT 1
print(qs[0])  # Ещё один запрос!
print(qs[1])  # И ещё один!

Если нужно обращаться к элементам по индексу — сначала преобразуйте в список:

users = list(User.objects.all())
print(users[0])  # Из памяти
print(users[1])  # Из памяти

N+1: классическая проблема

# 1 запрос на посты
posts = Post.objects.all()

for post in posts:
    # N запросов на авторов!
    print(post.author.name)

Если постов 100, будет 101 запрос. post.author — это ForeignKey, и Django лениво загружает связанный объект при обращении.

Решения: select_related и prefetch_related. И они делают разные вещи.

select_related: JOIN в SQL

select_related использует SQL JOIN для загрузки связанных объектов в одном запросе:

posts = Post.objects.select_related('author').all()

for post in posts:
    print(post.author.name)  # Без дополнительных запросов

Генерируемый SQL:

SELECT post.*, author.*
FROM posts
INNER JOIN authors ON post.author_id = author.id

Один запрос, все данные. Но есть ограничения:

Работает только с ForeignKey и OneToOne:

# OK
Post.objects.select_related('author')

# Ошибка — ManyToMany нельзя
Post.objects.select_related('tags')  # Не работает

JOIN увеличивает объём данных:

# Если у автора 1000 постов, данные автора дублируются 1000 раз
Post.objects.select_related('author')

Можно цеплять через __:

# Загружаем автора и его компанию
Post.objects.select_related('author__company')

prefetch_related: отдельные запросы + склейка в Python

prefetch_related делает отдельный запрос для связанных объектов и склеивает результаты в Python:

posts = Post.objects.prefetch_related('tags').all()

for post in posts:
    for tag in post.tags.all():  # Без дополнительных запросов
        print(tag.name)

Генерируемые запросы:

-- Запрос 1: посты
SELECT * FROM posts

-- Запрос 2: теги для этих постов
SELECT tags.*, post_tags.post_id
FROM tags
INNER JOIN post_tags ON tags.id = post_tags.tag_id
WHERE post_tags.post_id IN (1, 2, 3, ...)  -- ID из первого запроса

Два запроса вместо N+1. Django сам связывает теги с постами в памяти.

Работает с ManyToMany и обратными ForeignKey:

# ManyToMany
Post.objects.prefetch_related('tags')

# Обратный ForeignKey (все комментарии поста)
Post.objects.prefetch_related('comments')

# Можно и с ForeignKey, но select_related эффективнее
Post.objects.prefetch_related('author')

Осторожно с фильтрацией:

posts = Post.objects.prefetch_related('comments').all()

for post in posts:
    # Это убивает prefetch
    approved = post.comments.filter(approved=True)

Вызов .filter() на связанном менеджере создаёт новый QuerySet и делает запрос. Prefetch бесполезен.

Prefetch object: фильтрация в prefetch

Для фильтрации связанных объектов испол��зуйте Prefetch:

from django.db.models import Prefetch

posts = Post.objects.prefetch_related(
    Prefetch(
        'comments',
        queryset=Comment.objects.filter(approved=True),
        to_attr='approved_comments'  # Сохраняем в отдельный атрибут
    )
)

for post in posts:
    for comment in post.approved_comments:  # Список, не QuerySet!
        print(comment.text)

to_attr тут для того, чтобы результат сохраняется как список в указанный атрибут. Без to_attr результат попадает в стандартный менеджер, но его тип меняется.

Сложный пример — вложенные prefetch:

Post.objects.prefetch_related(
    Prefetch(
        'comments',
        queryset=Comment.objects.select_related('author').filter(approved=True)
    )
)

Prefetch комментарии, для каждого комментария JOIN автора.

only() и defer(): частичная загрузка

По дефолту Django загружает все поля. Для широких таблиц это расточительно.

# Загружаем только нужные поля
users = User.objects.only('id', 'name')

for user in users:
    print(user.name)      # OK
    print(user.email)     # Дополнительный запрос! Поле не было загружено

defer() — обратная операция, исключает поля:

# Всё, кроме тяжёлого bio
users = User.objects.defer('bio')

Обращение к отложенному полю вызывает запрос. Это хуже N+1, потому что незаметно.

Для связанных объектов в select_related:

Post.objects.select_related('author').only('title', 'author__name')

values() и values_list(): когда ORM-объекты не нужны

Если нужны только данные без объектов:

# Словари
User.objects.values('id', 'name')
# [{'id': 1, 'name': 'Nikolay'}, {'id': 2, 'name': 'Bob'}]

# Кортежи
User.objects.values_list('id', 'name')
# [(1, 'Alice'), (2, 'Eva')]

# Плоский список
User.objects.values_list('name', flat=True)
# ['Nikolay', 'Eva']

Быстрее, меньше памяти — нет создания ORM-объектов. Но теряется функциональность модели.

Агрегация и аннотации

annotate() добавляет вычисляемые поля к каждому объекту:

from django.db.models import Count, Avg

# Количество комментариев для каждого поста
posts = Post.objects.annotate(comment_count=Count('comments'))

for post in posts:
    print(f"{post.title}: {post.comment_count} comments")

SQL:

SELECT post.*, COUNT(comments.id) as comment_count
FROM posts
LEFT JOIN comments ON comments.post_id = posts.id
GROUP BY posts.id

aggregate() возвращает словарь с агрегатом по всему QuerySet:

from django.db.models import Avg

result = Post.objects.aggregate(avg_views=Avg('views'))
# {'avg_views': 1234.5}

F-выражения: операции на уровне базы

F() позволяет ссылаться на поля в запросах:

from django.db.models import F

# Увеличить счётчик без загрузки объекта
Post.objects.filter(id=1).update(views=F('views') + 1)

SQL:

UPDATE posts SET views = views + 1 WHERE id = 1

Атомарная операция.

Сравнение полей:

# Посты, где комментариев больше, чем лайков
Post.objects.filter(comments_count__gt=F('likes_count'))

Subquery и OuterRef: подзапросы

Для сложных запросов:

from django.db.models import Subquery, OuterRef

# Последний комментарий для каждого поста
latest_comment = Comment.objects.filter(
    post=OuterRef('pk')
).order_by('-created').values('text')[:1]

posts = Post.objects.annotate(
    latest_comment_text=Subquery(latest_comment)
)

SQL:

SELECT post.*, (
    SELECT text FROM comments
    WHERE comments.post_id = posts.id
    ORDER BY created DESC
    LIMIT 1
) as latest_comment_text
FROM posts

OuterRef('pk') ссылается на поле из внешнего запроса.

Raw SQL: когда ORM не хватает

Иногда нужен чистый SQL:

# raw() — возвращает объекты модели
users = User.objects.raw('SELECT * FROM users WHERE complex_condition')

# Полностью кастомный запрос
from django.db import connection

with connection.cursor() as cursor:
    cursor.execute("SELECT COUNT(*) FROM users WHERE ...")
    result = cursor.fetchone()

raw() всё ещё маппит результаты на модель. Для произвольных запросов есть connection.cursor().

django-debug-toolbar и логирование

Включите логирование SQL:

# settings.py
LOGGING = {
    'handlers': {
        'console': {'class': 'logging.StreamHandler'},
    },
    'loggers': {
        'django.db.backends': {
            'level': 'DEBUG',
            'handlers': ['console'],
        },
    },
}

В коде:

from django.db import connection

# После выполнения запросов
for query in connection.queries:
    print(query['sql'], query['time'])

django-debug-toolbar показывает все запросы на странице с дублями и временем.


QuerySet — это база. Понимание его механики помогает избегать мелких, но очень вредных ошибок.

Потянете курс Django-разработчик?
Потянете курс Django-разработчик?

Понимание того, как работает ORM, — признак роста от «пишу код» к «проектирую систему». На курсе «Django-разработчик» эти механики разбирают как часть целостного бэкенд-подхода: от архитектуры и запросов к БД до API и фронтенда, с упором на практику и реальные задачи уровня Middle+. Такой разбор помогает писать масштабируемый код. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:

  • 4 февраля в 20:00. «Подключение OpenAPI Swagger к Django-REST-Framework». Записаться

  • 12 февраля в 20:00. «Django + Telegram Bot: как связать веб-приложение и мессенджер». Записаться

  • 18 февраля в 20:00. «Знакомство с Vue.js: основы для начинающих». Записаться