QuerySet, filter, exclude, order_by, values, annotate, aggregate
ORM связывает модели Python с таблицами базы данных. Его главная единица — не список, а QuerySet: объект, который описывает будущий SQL-запрос и умеет дополнять его новыми условиями.
Материал ниже актуален для Django 5.2 LTS и 6.0.
Вызовы filter(), exclude() и order_by() обычно не обращаются к базе. Они возвращают новый QuerySet, поэтому условия можно собирать поэтапно:
posts = Post.objects.filter(status=Post.Status.PUBLISHED)
if author_id:
posts = posts.filter(author_id=author_id)
posts = posts.order_by("-published_at")SQL выполняется при вычислении результата: во время итерации, преобразования в list, вызова len() или bool(), доступа по индексу, а также при first(), exists(), count() и других методах, которые должны вернуть готовое значение.
После обычной итерации Django сохраняет полученные объекты в кэше конкретного QuerySet. Повторная итерация по тому же объекту не делает новый запрос. Однако iterator() работает иначе, а новый QuerySet, даже построенный из тех же условий, имеет собственный кэш.
Аргументы одного filter() соединяются оператором AND. exclude() добавляет отрицательное условие. Двойное подчёркивание отделяет поле от lookup-оператора или позволяет пройти по связи:
posts = (
Post.objects
.filter(
status=Post.Status.PUBLISHED,
title__icontains="django",
views__gte=100,
author__is_active=True,
)
.exclude(category__slug="archive")
)Частые lookup-операторы: exact, iexact, contains, icontains, gt, gte, lt, lte, in, range и isnull. Сравнение без суффикса означает exact.
Для условий с OR и NOT используют объекты Q:
from django.db.models import Q
posts = Post.objects.filter(
Q(title__icontains=query) | Q(body__icontains=query),
status=Post.Status.PUBLISHED,
)Позиционные объекты Q должны стоять перед именованными аргументами. Иначе Python не сможет разобрать вызов.
Если created_at — DateTimeField, условие created_at__lte=date(2026, 7, 19) обычно включает только полночь указанной даты. Надёжнее использовать полуоткрытый интервал: начало включительно, следующая граница исключительно.
from datetime import timedelta
posts = Post.objects.filter(
created_at__gte=start_date,
created_at__lt=end_date + timedelta(days=1),
)При включённом USE_TZ сравнивайте поле с часовыми значениями, осознанно привязанными к нужной зоне. В production границы пользовательского дня часто сначала переводят в UTC.
order_by("field") сортирует по возрастанию, а минус задаёт убывание. Для стабильной пагинации добавляйте уникальное поле, иначе строки с одинаковой датой могут менять порядок:
posts = Post.objects.order_by("-published_at", "-pk")
page = posts[:20] # SQL LIMIT 20order_by("?") удобен для демонстрации, но случайная сортировка большой таблицы обычно дорогая. После среза с ограничением QuerySet уже нельзя произвольно фильтровать: сначала сформируйте условия, затем применяйте пагинацию.
Если имя сортировки приходит от пользователя, его нельзя передавать в order_by() без проверки. Разрешите только известные варианты:
ORDERING = {
"new": ("-published_at", "-pk"),
"old": ("published_at", "pk"),
"popular": ("-views", "-pk"),
}
ordering = ORDERING.get(request.GET.get("order"), ORDERING["new"])
posts = posts.order_by(*ordering)Такой словарь одновременно поддерживает оба направления сортировки и не позволяет пользователю обращаться к произвольным полям.
get() выражает важное ожидание: условию должна соответствовать ровно одна запись. При отсутствии объекта он выбрасывает DoesNotExist, при нескольких — MultipleObjectsReturned.
from django.shortcuts import get_object_or_404
post = get_object_or_404(
Post,
slug=slug,
status=Post.Status.PUBLISHED,
)filter(...).first() возвращает объект или None, но скрывает ошибку, если записи неожиданно продублировались. Поэтому для уникального slug лучше обеспечить ограничение в базе и использовать get() либо get_object_or_404().
exists(), count() и len() решают разные задачиexists() отвечает, существует ли хотя бы одна строка;count() просит базу подсчитать строки;len(queryset) вычисляет выборку и считает объекты Python.Если объекты всё равно понадобятся сразу после проверки, отдельный exists() может дать лишний запрос. В таком случае разумно один раз вычислить QuerySet и проверить полученный список. Если нужны только факт существования или количество, не загружайте полные модели.
values() и values_list() тоже возвращают QuerySetЭти методы меняют форму каждой строки, но не превращают результат в готовый список:
rows = Post.objects.values("id", "title", "author__username")
titles = Post.objects.values_list("title", flat=True)
named_rows = Post.objects.values_list("id", "title", named=True)rows — ленивый QuerySet словарей, titles — ленивый QuerySet отдельных значений, named_rows — ленивый QuerySet именованных кортежей. Методы модели у таких строк недоступны. Это хороший формат для отчётов, но не универсальная «оптимизация»: связи, сериализация и дальнейшая бизнес-логика могут потребовать полноценных объектов.
aggregate() вычисляет итог по всей выборке и сразу возвращает словарь. annotate() добавляет выражение к каждой строке и возвращает QuerySet:
from django.db.models import Avg, Count, Q
summary = Post.objects.filter(
status=Post.Status.PUBLISHED,
).aggregate(avg_views=Avg("views"))
posts = Post.objects.annotate(
approved_comments=Count(
"comments",
filter=Q(comments__is_approved=True),
),
)Осторожно с несколькими связями «многие ко многим» или обратными внешними ключами. Два Count в одном запросе могут перемножить строки при JOIN и завысить оба результата. Для простого подсчёта помогает distinct=True:
posts = Post.objects.annotate(
comment_count=Count("comments", distinct=True),
like_count=Count("likes", distinct=True),
)Но distinct=True не исправляет любую агрегацию. Для сумм и более сложных расчётов могут понадобиться подзапросы или отдельные запросы. Всегда смотрите сгенерированный SQL и проверяйте результат на данных, где у одной записи есть несколько объектов по обеим связям.
QueryDict хранит несколько значений одного ключа. request.GET.dict() оставляет только одно, поэтому для тегов нужен getlist():
tags = request.GET.getlist("tag")
posts = Post.objects.filter(status=Post.Status.PUBLISHED)
for slug in tags: # AND: пост должен иметь каждый выбранный тег
posts = posts.filter(tags__slug=slug)
posts = posts.distinct()Цикл выражает логику AND. Один фильтр tags__slug__in=tags выражал бы OR. Это разные требования, и их следует назвать в интерфейсе и тестах.
Базовый API QuerySet стабилен много лет, поэтому старый код с filter(), Q, annotate() и select_related() часто остаётся рабочим. Legacy-проблемы здесь обычно связаны не с удалёнными методами, а с неверными предположениями: QuerySet принимают за список, доверяют пользовательской сортировке или не замечают умножение строк при нескольких JOIN.
Отдельно проверяйте поведение конкретной поддерживаемой СУБД: регистронезависимый поиск, регулярные выражения и порядок NULL могут отличаться. ORM переносит синтаксис, но не отменяет различия баз данных.
Вы должны уметь предсказать момент выполнения SQL, выбрать между get(), first(), exists() и count(), безопасно собрать фильтры из запроса и объяснить, почему агрегаты по нескольким связям требуют теста на корректность.
Далее: ORM: Продвинутые техники