annotate, aggregate, Q и F

Считать суммы, средние и количества лучше в базе, а не в Python. Для этого в ORM есть aggregate, annotate, F и Q — и каждый закрывает свой класс задач.

aggregate сводит всю выборку к одному набору чисел, annotate добавляет вычисленное значение к каждой строке, F ссылается на поле прямо в SQL, а Q собирает условия с OR и NOT.

Соблазн посчитать «среднюю цену» или «число заказов на клиента» циклом в Python велик, но это и медленно, и снова приводит к N+1. СУБД умеет агрегировать встроенно и быстро — ORM лишь даёт удобный мост к этим возможностям.

aggregate: одно число на всю выборку

aggregate() применяет агрегатную функцию ко всему QuerySet и возвращает словарь с результатом. Это терминальная операция — она сразу выполняет запрос.

from django.db.models import Count, Sum, Avg, Min, Max

Order.objects.aggregate(Avg("total"))
# {"total__avg": 523.4}

Order.objects.aggregate(
    orders=Count("id"),
    revenue=Sum("total"),
    biggest=Max("total"),
)
# {"orders": 1240, "revenue": 648_900, "biggest": 9_800}

Ключи можно назвать самому (orders=...), иначе Django сгенерирует имя вида поле__функция. Один SELECT с COUNT/SUM вместо выгрузки тысяч строк в Python.

annotate: значение на каждую строку (группировка)

Если нужно не одно число на всё, а по числу для каждого объекта — это annotate(). Он добавляет к каждой строке вычисленное «виртуальное поле». Классика — количество связанных объектов:

from django.db.models import Count

authors = Author.objects.annotate(num_articles=Count("articles"))
for a in authors:
    print(a.name, a.num_articles)   # число статей доступно как обычный атрибут

За кулисами это GROUP BY author.id с COUNT по статьям — один запрос вместо N. Аннотированное поле полноценно: по нему можно фильтровать и сортировать.

Author.objects.annotate(num=Count("articles")) \
    .filter(num__gte=10) \
    .order_by("-num")
# авторы с 10+ статьями, по убыванию числа статей

Важно различать момент фильтрации. Условие до агрегата кладут в filter(...) перед annotate — оно попадёт в WHERE. Условие на сам агрегат — в filter() после annotate — оно попадёт в HAVING. Можно фильтровать и внутри самого Count через аргумент filter=:

from django.db.models import Count, Q

Author.objects.annotate(
    published=Count("articles", filter=Q(articles__published=True))
)
# у каждого автора — число именно опубликованных статей

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

F ссылается на значение поля в момент выполнения SQL, не вытаскивая его в Python. Это даёт две вещи: операции «поле относительно самого себя» и сравнение двух полей одной строки.

from django.db.models import F

# увеличить просмотры на 1 — БЕЗ гонки данных
Article.objects.filter(pk=10).update(views=F("views") + 1)
# SQL: UPDATE article SET views = views + 1 WHERE id = 10

Почему это лучше, чем a.views += 1; a.save()? Питоновский вариант читает текущее значение, прибавляет в памяти и записывает — между чтением и записью другой процесс может изменить счётчик, и инкремент потеряется. F("views") + 1 выполняет сложение атомарно внутри СУБД, гонки нет. Плюс это один запрос на все строки, а не цикл.

# сравнить два поля одной строки
Product.objects.filter(sold__gt=F("stock"))
# товары, проданных больше, чем было на складе (аномалия)

# арифметика между полями в аннотации
Product.objects.annotate(remaining=F("stock") - F("sold"))

Q-объекты: сложные условия с OR и NOT

Аргументы filter() всегда соединяются через AND. Чтобы получить OR, отрицание или скобки, нужны Q-объекты. Их комбинируют операторами | (ИЛИ), & (И) и ~ (НЕ).

from django.db.models import Q

# опубликованные ИЛИ закреплённые
Article.objects.filter(Q(published=True) | Q(pinned=True))

# (django ИЛИ python) И НЕ забанен автор
Article.objects.filter(
    (Q(tags__name="django") | Q(tags__name="python")) & ~Q(author__is_banned=True)
)

Q можно смешивать с обычными именованными аргументами, но Q-объекты идут первыми: filter(Q(a) | Q(b), is_active=True). Это удобно для динамического поиска — условия собирают в переменную и комбинируют по флагам формы.

query = Q()
if search_text:
    query &= (Q(title__icontains=search_text) | Q(body__icontains=search_text))
if only_published:
    query &= Q(published=True)

Article.objects.filter(query)   # условие собрано динамически

SQL под капотом

Аннотация с группировкой превращается примерно в такой запрос — приведён для чтения, чтобы видеть GROUP BY:

SELECT author.id, author.name, COUNT(article.id) AS num_articles
FROM author
LEFT JOIN article ON article.author_id = author.id
GROUP BY author.id, author.name
HAVING COUNT(article.id) >= 10
ORDER BY num_articles DESC;

Видно, что annotate(Count(...)) даёт GROUP BY + агрегат, фильтр на агрегат уходит в HAVING, а сортировка по аннотации работает как по обычной колонке.

Как это работает под капотом

Все эти выражения — Count, Sum, F, Q — это узлы того же дерева Query, что и filter. ORM не вычисляет их в Python; он транслирует их в SQL и поручает счёт самой СУБД, которая делает это на порядки быстрее, да ещё и не гоняет данные по сети. annotate добавляет агрегат в SELECT и при необходимости GROUP BY; одновременное использование нескольких агрегатов или комбинация annotate с несколькими JOIN может приводить к «двойному счёту» (декартово произведение строк), и тогда внутри агрегата ставят distinct=True. F компилируется в ссылку на колонку, поэтому выражение F("a") + F("b") становится частью SQL и вычисляется построчно базой. Q — это просто узел дерева условий, который умеет соединяться булевыми операторами и переворачиваться через ~.

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

  • Считать агрегаты циклом в Python. Перебрать тысячи строк ради суммы — это и медленно, и часто N+1; для этого есть aggregate/annotate, считающие в базе.
  • Путать aggregate и annotate. Первый сводит всю выборку к словарю чисел, второй добавляет число к каждой строке. Нужно «по объекту» — это annotate.
  • Делать инкремент как obj.x += 1; obj.save(). Между чтением и записью возможна гонка; update(x=F("x") + 1) атомарен в СУБД и безопасен при конкуренции.
  • Ждать OR от обычных аргументов filter(). Они всегда AND; для OR/NOT/скобок нужны Q-объекты с |, &, ~.
  • Завышенные числа в аннотации при нескольких связях. Несколько JOIN раздувают строки и агрегат считает лишнее; лечится distinct=True внутри Count.

Итоги

  • aggregate возвращает словарь сводных чисел по всей выборке за один SELECT.
  • annotate добавляет вычисленное поле к каждой строке (часто через GROUP BY); по нему можно фильтровать и сортировать.
  • Фильтр до агрегата уходит в WHERE, фильтр на сам агрегат — в HAVING; точечный фильтр внутри агрегата задаётся аргументом filter=Q(...).
  • F выполняет операции и сравнения полей прямо в базе — это и атомарность (без гонок при инкременте), и один запрос вместо цикла.
  • Q даёт OR, NOT и скобки через |, &, ~ и удобен для динамической сборки условий.
Проверьте себя
1. Чем aggregate() отличается от annotate()?
AОни делают одно и то же, просто разные имена
Baggregate сводит всю выборку к одному словарю чисел, а annotate добавляет вычисленное значение к каждой строке (обычно через GROUP BY)
Cannotate работает только с числами, aggregate — со строками
Daggregate ленив, а annotate выполняется сразу
2. Почему Article.objects.filter(pk=10).update(views=F("views") + 1) предпочтительнее, чем a.views += 1; a.save()?
AПотому что F-выражение работает быстрее за счёт кэша
BПотому что инкремент через F выполняется атомарно внутри СУБД (UPDATE ... SET views = views + 1) и не теряется при гонке двух процессов
CПотому что save() не умеет обновлять числовые поля
DПотому что F() автоматически создаёт индекс
3. Как во filter() выразить условие «published=True ИЛИ pinned=True»?
Afilter(published=True, pinned=True)
Bfilter(Q(published=True) | Q(pinned=True))
Cfilter(published=True).filter(pinned=True)
Dfilter(published=True or pinned=True)