Как устроен QuerySet в Django и почему он ленивый
QuerySet не ходит в базу, пока вы его не заставите. Разбираем, что происходит внутри при итерации, срезе и len(), где ленивость экономит запросы, а где втихую отправляет их пачками.

Вы пишете qs = Article.objects.filter(published=True) — и в базу не уходит ни одного запроса. Пишете qs = qs.exclude(author=None) — снова тишина. А потом в шаблоне цикл по qs внутри другого цикла превращается в 300 SELECT-ов, и сервис отдаёт ответ за две секунды вместо двадцати миллисекунд. Разбираем, почему QuerySet ленивый, в какой именно момент он бьёт по базе и что из-за этого стоит ловить на ревью. Ориентируемся на Django 5.x, но описанная механика не менялась много лет.
Что значит «ленивый» на практике
QuerySet — это не список строк из базы. Это объект, который хранит описание запроса: какие фильтры навесить, что исключить, как отсортировать, какие поля выбрать. Пока вы только строите цепочку .filter().exclude().order_by(), Django накапливает эти условия в объекте Query внутри QuerySet и в SQL их не превращает.
Каждый метод-цепочка возвращает новый QuerySet с обновлённым Query, а не меняет старый. Поэтому цепочки можно передавать в функции, комбинировать, класть в переменные — и ничего не выполнится, пока кто-то не запросит реальные данные.
Ленивость нужна не ради красоты. Она позволяет собрать полное условие из разных мест кода и отправить в базу один SQL вместо нескольких промежуточных выборок.
Что происходит внутри при итерации
Данные материализуются, когда QuerySet впервые обходят циклом. Тогда вызывается внутренний механизм, который компилирует Query в SQL, выполняет его через курсор и создаёт объекты моделей из полученных строк. Результат складывается в кеш результатов — атрибут _result_cache самого QuerySet.
Ключевой момент: кеш живёт на конкретном экземпляре QuerySet. Пока вы держите ту же переменную, повторный цикл по ней в базу не пойдёт.
# Django 5.x
articles = Article.objects.filter(published=True)
for a in articles: # здесь уходит SQL, результат кешируется
print(a.title)
for a in articles: # второй проход — из кеша, запроса нет
print(a.slug)
# А вот так — новый QuerySet, кеша нет, запрос повторится:
for a in Article.objects.filter(published=True):
...
Какие операции запускают запрос
Не всё, что выглядит как работа с данными, одинаково бьёт по базе. Часть операций материализует весь набор, часть шлёт отдельный агрегирующий SQL, часть остаётся ленивой.
| Операция | Что делает с базой | Что вернётся |
|---|---|---|
for x in qs | Выполняет SELECT, кеширует результат | Объекты моделей |
list(qs) | То же — принудительно материализует | Список объектов |
len(qs) | Материализует весь набор, если кеша нет | Число строк |
qs.count() | Отдельный SELECT COUNT(*), кеш не трогает | Число |
bool(qs) / if qs: | Материализует весь набор | True/False |
qs.exists() | SELECT с LIMIT 1 | True/False |
qs[5] | SQL с LIMIT/OFFSET | Один объект |
qs[:10] | Ленивый — добавляет LIMIT, запрос не шлёт | Новый QuerySet |
qs.filter(...) | Ленивый — новый QuerySet | QuerySet |
Разница между count() и len() здесь принципиальна. Если вам уже нужны объекты и вы всё равно будете их перебирать — len(qs) над уже материализованным набором бесплатен. Но если вам нужно только число, а объекты не нужны, len() притащит из базы все строки целиком, тогда как count() отдаст одно число из COUNT(*).
Срезы: где ленивость и где нет
Срез с диапазоном (qs[:20]) остаётся ленивым и превращается в LIMIT/OFFSET. А обращение по индексу (qs[0]) — уже выполнение: Django должен вернуть конкретный объект. Отрицательные индексы (qs[-1]) не поддерживаются — база не умеет отсчитывать с конца, разворачивайте сортировку через order_by().
Ленивость и проблема N+1
Именно ленивость порождает самую частую беду на проде — N+1 запрос. QuerySet тянет только поля своей модели. Обращение к связанному объекту через ForeignKey — это отдельный ленивый доступ, который в цикле выстреливает по запросу на каждую строку.
# Плохо: 1 запрос на статьи + по 1 на каждого автора
for article in Article.objects.filter(published=True):
print(article.author.name) # тут скрытый SELECT на каждой итерации
# Хорошо: 1 запрос со связкой через JOIN
for article in Article.objects.filter(published=True).select_related('author'):
print(article.author.name) # автор уже подтянут
Для связей «многие ко многим» и обратных ForeignKey работает prefetch_related — он делает отдельный запрос на связанные объекты и склеивает их в Python, а не через JOIN. Разница между select_related и prefetch_related — тема отдельного разбора; здесь важно, что оба нужно навесить до материализации, потому что после итерации QuerySet уже закеширован и переоптимизировать его нельзя.
Что смотреть на ревью
Ленивость невидима в коде — SQL не написан явно, и по глазам не всегда понятно, сколько запросов уйдёт. Конкретные формулировки замечаний, которые реально помогают автору, а не «оптимизируйте запросы»:
- «Здесь
len(qs), а объекты дальше не используются — замените на.count(), иначе тянем весь набор ради числа». - «Внутри цикла обращение к
obj.category.title— добавьте.select_related('category')в исходный QuerySet, сейчас это N+1». - «
if qs:для проверки наличия материализует весь набор — используйтеqs.exists(), будетLIMIT 1». - «QuerySet передаётся в шаблон и там перебирается дважды — первый проход кеширует, второй бесплатен, всё ок; но
{{ qs.count }}в том же шаблоне пошлёт ещё одинCOUNT— либо считайте черезlenпосле цикла, либо посчитайте один раз во view». - «QuerySet в аргументе с дефолтом
def f(qs=Model.objects.all())— запрос сформируется на импорте модуля, вынесите внутрь функции».
Отдельно ловите места, где один и тот же логический набор данных строится заново в разных функциях: каждый новый QuerySet — новый поход в базу, кеш между ними не разделяется.
Как увидеть реальные запросы
Не гадайте по коду — измерьте. В окружении с включённым DEBUG Django собирает все SQL в django.db.connection.queries.
from django.db import connection, reset_queries
reset_queries()
# ...код, который вы проверяете...
for article in Article.objects.filter(published=True):
print(article.author.name)
print(len(connection.queries)) # число запросов
for q in connection.queries:
print(q['sql'])
В тестах для этого есть assertNumQueries из django.test — он падает, если число запросов не совпало с ожидаемым. Это лучший способ зафиксировать «здесь ровно 2 запроса» и не дать регрессии проскочить на ревью.
Граничные случаи, о которых забывают
Ленивость перестаёт быть безопасной в нескольких ситуациях:
- Данные меняются между обращениями. Первый цикл закешировал результат. Если между двумя проходами кто-то обновил строки в базе, второй проход отдаст старые данные из кеша, а не свежие. Нужно свежее — стройте новый QuerySet или вызывайте
.all()для копии без кеша. - Большие выборки. Итерация тянет весь набор в память разом. Для миллионов строк используйте
.iterator()— он читает результат порциями через серверный курсор и не кеширует, но тогда повторный проход снова пойдёт в базу. - Комбинация с
select_relatedпосле среза. Навесить оптимизацию послеqs[:10]уже нельзя так, как хотелось бы — порядок методов в цепочке влияет на итоговый SQL. Сначала оптимизации и фильтры, срез — в конце.
Что делать дальше
Поставьте django-debug-toolbar в dev-окружении: панель SQL покажет число запросов и дубли на каждой странице без ручного разбора connection.queries. На критичных вьюхах закройте поведение тестом с assertNumQueries, чтобы N+1 не вернулся при следующем рефакторинге. И держите в голове одно правило при чтении чужого кода: если в шаблоне или цикле есть обращение к связанному объекту, ищите select_related или prefetch_related в соответствующем QuerySet — их отсутствие почти всегда означает скрытые запросы.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Django documentation — QuerySet API reference, Django documentation — Making queries: QuerySets are lazy
Частые вопросы
Чем count() отличается от len() у QuerySet?
А если у меня Django 4.2, механика та же?
Почему повторный цикл по QuerySet не идёт в базу?
Я добавил select_related, а запросов всё равно много — почему?
Как поймать N+1 до того, как это заметят на проде?
Опасно ли кеширование результата QuerySet при изменении данных?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.