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 = TrueQuerySet хранит параметры запроса (фильтры, сортировки, лимиты), но идёт в базу только когда данные реально нужны.
Это называется 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.idaggregate() возвращает словарь с агрегатом по всему 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 postsOuterRef('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 — это база. Понимание его механики помогает избегать мелких, но очень вредных ошибок.

Понимание того, как работает ORM, — признак роста от «пишу код» к «проектирую систему». На курсе «Django-разработчик» эти механики разбирают как часть целостного бэкенд-подхода: от архитектуры и запросов к БД до API и фронтенда, с упором на практику и реальные задачи уровня Middle+. Такой разбор помогает писать масштабируемый код. Чтобы узнать, подойдет ли вам программа курса, пройдите вступительный тест.
Для знакомства с форматом обучения и экспертами приходите на бесплатные демо-уроки:
4 февраля в 20:00. «Подключение OpenAPI Swagger к Django-REST-Framework». Записаться
12 февраля в 20:00. «Django + Telegram Bot: как связать веб-приложение и мессенджер». Записаться
18 февраля в 20:00. «Знакомство с Vue.js: основы для начинающих». Записаться
