Aggregation, Expressions & Functions
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Django 6.1 (6.1.1, released 2026-09-02; 6.1.2 is scheduled for 2026-10-06) on Python 3.12 to 3.14, with the 5.2 LTS differences noted where they matter, and was checked on 2026-10-03. Every example ran on Django 6.1.1 and Python 3.13 with SQLite 3.53 in a throw-away project with a small catalog app (the Bookshelf catalogue that grows across this section). Where an example depends on a database feature that SQLite lacks or implements differently (UUID7 needs PostgreSQL 18 or Python 3.14 on SQLite, StringAgg(distinct=True) is limited on SQLite, integer division) the page says so; nothing here needs PostgreSQL or Redis to be understood, and the SQL shown is what SQLite received.
A model gives you rows. Reports, leaderboards, "books with at least two reviews", "the latest review of each book" and "rank within the author" need the database to compute things, because doing it in Python means loading every row and looping in the interpreter. Django’s ORM exposes the database’s expression language through a handful of building blocks that compose: aggregates collapse rows into numbers, expressions describe computations on columns, conditional expressions add CASE WHEN, subqueries and Exists correlate one query with another, window functions compute over a set of rows without collapsing them, FilteredRelation filters a JOIN itself, and the database functions wrap the SQL functions of the backend. This page covers each of them, the order-of-operations traps, and the changes since Django 2. The query basics (filter, Q, lookups, values) are in Making Queries, and reducing the number of queries is in Query Optimization & Fetch Modes.
The Bookshelf models used here
The examples use three small models: an Author with many Book rows, and a Book with many Review rows. The seed data is six books by three authors with nine reviews, so the numbers in the comments can be checked by hand.
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
def __str__(self):
return self.name
class Book(models.Model):
class Format(models.TextChoices):
PAPERBACK = "PB", "Paperback"
HARDCOVER = "HC", "Hardcover"
EBOOK = "EB", "E-book"
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name="books")
pages = models.PositiveIntegerField()
price = models.DecimalField(max_digits=6, decimal_places=2)
format = models.CharField(max_length=2, choices=Format, default=Format.PAPERBACK)
published = models.DateField()
def __str__(self):
return self.title
class Review(models.Model):
book = models.ForeignKey(Book, on_delete=models.CASCADE, related_name="reviews")
rating = models.PositiveSmallIntegerField()
created = models.DateTimeField()
approved = models.BooleanField(default=True)
Source: Model field reference. The fields are explained in Models & Fields and the foreign keys in Relationships.
All the snippets below start from:
from decimal import Decimal
from django.db.models import (
Aggregate, AnyValue, Avg, BitOr, BooleanField, Case, CharField, Count, Exists,
ExpressionWrapper, F, FilteredRelation, FloatField, Func, IntegerField, Max, Min,
OuterRef, Q, StdDev, StringAgg, Subquery, Sum, Value, When, Window,
)
from django.db.models.functions import Cast, Coalesce, Rank
from catalog.models import Author, Book, Review
Aggregation with aggregate() and annotate()
An aggregate function turns many rows into one value: COUNT, SUM, AVG, MIN, MAX and a few statistical ones. Django has two ways to ask the database for them, and the difference is the most important idea on this page.
-
aggregate()is a terminal clause. It collapses the whole queryset into a single dictionary and returns it. The queryset is evaluated immediately. -
annotate()adds an aggregate (or any expression) as an extra attribute on each object of the queryset, producing aGROUP BYover the objects. It returns a queryset, so it can be filtered, ordered and sliced afterwards.
aggregate(): one dictionary
Each argument is an aggregate expression. The dictionary keys are generated from the field and the function (price__avg), or you choose them with keyword arguments.
Book.objects.aggregate(Avg("price"))
# {'price__avg': Decimal('20')}
Book.objects.aggregate(Min("price"), Max("price"), total=Sum("pages"))
# {'total': 1700, 'price__min': Decimal('5'), 'price__max': Decimal('40')}
Book.objects.aggregate(n=Count("id"), authors=Count("author", distinct=True))
# {'n': 6, 'authors': 3}
Unnamed arguments appear in the result under their generated name; keyword arguments are listed first, which is why total precedes price__min above. Aggregating an empty queryset returns None for Sum, Avg, Min and Max and 0 for Count. Pass default= to return something else; Django wraps the aggregate in Coalesce for you.
Book.objects.filter(price__gt=1000).aggregate(total=Sum("price"))
# {'total': None}
Book.objects.filter(price__gt=1000).aggregate(total=Sum("price", default=0))
# {'total': Decimal('0')}
Source: QuerySet.aggregate() and the default argument. The default argument has existed since Django 4.0; before that, books and old code wrap the aggregate in Coalesce by hand.
The asynchronous twin is aaggregate(), which is awaited and returns the same dictionary; it is awaited from async code (an async view or an async test) and ran in the test shown later. In a synchronous view nothing changes.
total = await Book.objects.aaggregate(total=Sum("pages"))
# {'total': 1700}
Source: QuerySet.aggregate() and Asynchronous support.
The built-in aggregates
All of these live in django.db.models since Django 6.0 or 6.1; before that, some of them were only available in django.contrib.postgres.
| Aggregate | Result | Notes |
|---|---|---|
|
Number of non-null values ( |
|
|
Sum, mean, smallest and largest value. |
|
|
Population or sample standard deviation and variance. |
|
|
Values concatenated into one string. |
Added to |
|
An arbitrary non-null value of the group. |
New in 6.0. SQLite, MySQL, Oracle and PostgreSQL 16+. |
|
Bitwise AND, OR and XOR over the group. |
In |
Book.objects.aggregate(titles=StringAgg("title", delimiter=Value(", "), order_by="title"))
# {'titles': 'A1, A2, A3, B1, B2, C1'}
Book.objects.aggregate(any_format=AnyValue("format"), bits=BitOr("pages"), spread=StdDev("price"))
# {'any_format': 'PB', 'bits': 510, 'spread': Decimal('11.9023807142381')}
Source: Aggregation functions. The 6.0 and 6.1 release notes list StringAgg, AnyValue, the order_by argument and the BitAnd/BitOr/BitXor move: 6.0 and 6.1.
Conditional aggregates with filter=
Every aggregate accepts a filter= argument, a Q object that becomes a SQL FILTER (WHERE …) clause (or an equivalent CASE on databases that lack FILTER). It lets one query compute several differently-restricted numbers, which is far cheaper than several queries.
Book.objects.aggregate(
avg_paperback=Avg("price", filter=Q(format="PB")),
n_hardcover=Count("pk", filter=Q(format="HC")),
)
# {'avg_paperback': Decimal('10'), 'n_hardcover': 2}
Book.objects.annotate(n_five_star=Count("reviews", filter=Q(reviews__rating=5)))
# SELECT ..., COUNT("catalog_review"."id") FILTER (WHERE "catalog_review"."rating" = 5) AS "n_five_star"
# FROM "catalog_book" LEFT OUTER JOIN "catalog_review" ON (...) GROUP BY ...
Source: Aggregate filter argument. The filter argument was added in Django 2.0.
annotate(): one value per object
annotate() follows relationships, forwards and backwards, and groups by the primary key (and the other selected columns) of the model being queried.
for author in Author.objects.annotate(n_books=Count("books"), avg_price=Avg("books__price")):
print(author.name, author.n_books, author.avg_price)
# Ann 3 20
# Bob 2 10
# Cy 1 40
The annotation can then be used like any field: filter on it (becoming HAVING), order by it, or aggregate over it.
Author.objects.annotate(n=Count("books")).filter(n__gte=2).values_list("name", "n")
# [('Ann', 3), ('Bob', 2)]
Author.objects.annotate(n=Count("books")).aggregate(Avg("n"), Max("n"))
# {'n__avg': 2.0, 'n__max': 3}
SELECT "catalog_author"."id", "catalog_author"."name", COUNT("catalog_book"."id") AS "n"
FROM "catalog_author"
LEFT OUTER JOIN "catalog_book" ON ("catalog_author"."id" = "catalog_book"."author_id")
GROUP BY "catalog_author"."id", "catalog_author"."name"
HAVING COUNT("catalog_book"."id") >= 2
Source: Filtering on annotations and Aggregating annotations.
The same ORM expression runs in one query whatever the table size; Author.objects.annotate(n=Count("books")) costs one round trip where a loop with author.books.count() costs one per author (the N+1 problem of Query Optimization & Fetch Modes).
GROUP BY with values()
To group by something other than the whole object, call values() first. The fields named in values() become the GROUP BY columns and the following annotate() computes one row per group. The result is a queryset of dictionaries.
Book.objects.values("format").annotate(n=Count("id"), total=Sum("price")).order_by("format")
# [{'format': 'EB', 'n': 1, 'total': Decimal('30')},
# {'format': 'HC', 'n': 2, 'total': Decimal('60')},
# {'format': 'PB', 'n': 3, 'total': Decimal('30')}]
Book.objects.values("author__name").annotate(total=Sum("pages")).order_by("-total")
# [{'author__name': 'Ann', 'total': 900}, {'author__name': 'Cy', 'total': 500}, {'author__name': 'Bob', 'total': 300}]
SELECT "catalog_book"."format" AS "format", COUNT("catalog_book"."id") AS "n"
FROM "catalog_book" GROUP BY 1
The order matters. annotate().values() groups by the object and then trims the columns, whereas values().annotate() groups by the named columns. Also remember that a model’s Meta.ordering (or a previous order_by()) adds columns to the GROUP BY and splits groups; call .order_by() with the grouping fields (or no arguments) when a grouped query returns more rows than expected. Details are on Interaction with order_by().
Order of annotate() and filter(), and the double-join trap
When the aggregate and the filter touch the same multi-valued relationship, the order of the calls changes the result. A filter() placed before annotate() restricts the rows that are aggregated; placed after, a filter on the relationship adds a second join that multiplies the rows being counted.
Author.objects.filter(books__price__gt=10).annotate(n=Count("books")).values_list("name", "n")
# [('Ann', 2), ('Bob', 1), ('Cy', 1)] <- counts only the expensive books
Author.objects.annotate(n=Count("books")).filter(books__price__gt=10).values_list("name", "n")
# [('Ann', 6), ('Bob', 2), ('Cy', 1)] <- 3 books x 2 expensive books = 6 for Ann
The same multiplication bites when you aggregate over two different relationships in one query. Each extra join repeats the rows of the other, so every Count over the multiplied relationship needs distinct=True; Sum and Avg over a multiplied relationship cannot be fixed that way and need subqueries instead (see below).
Author.objects.annotate(books_n=Count("books"), reviews_n=Count("books__reviews")).values_list("name", "books_n", "reviews_n")
# [('Ann', 6, 6), ('Bob', 3, 2), ('Cy', 1, 1)] <- books counted once per review: wrong
Author.objects.annotate(
books_n=Count("books", distinct=True), reviews_n=Count("books__reviews")
).values_list("name", "books_n", "reviews_n")
# [('Ann', 3, 6), ('Bob', 2, 2), ('Cy', 1, 1)] <- correct
Source: Combining multiple aggregations.
The test below locks the behaviour in, and also shows how to assert that an annotated queryset is a single query.
class AggregationTests(TestCase):
def test_annotate_is_one_query(self):
with self.assertNumQueries(1):
rows = {a.name: a.n for a in Author.objects.annotate(n=Count("books"))}
self.assertEqual(rows, {"Ann": 2, "Bob": 1})
def test_two_joins_need_distinct(self):
wrong = Author.objects.annotate(
books_n=Count("books"), reviews_n=Count("books__reviews")
).get(name="Ann")
right = Author.objects.annotate(
books_n=Count("books", distinct=True), reviews_n=Count("books__reviews")
).get(name="Ann")
self.assertEqual((wrong.books_n, wrong.reviews_n), (3, 3))
self.assertEqual((right.books_n, right.reviews_n), (2, 3))
Source: assertNumQueries.
alias(): compute without selecting
alias() (Django 3.2) takes the same arguments as annotate() but does not add the value to the SELECT list. Use it for intermediate values you only filter or order by, or that you reuse in a later expression, so the database does not return a column you ignore.
Book.objects.alias(n=Count("reviews")).filter(n=0).values_list("title", flat=True)
# ['B2']
# annotate() the same value only when the caller needs to read it:
Book.objects.alias(avg=Avg("reviews__rating")).filter(avg__gte=4).annotate(rating=F("avg"))
Source: QuerySet.alias() and the 3.2 release notes.
Query expressions
An expression is an object that describes a value the database computes: a column reference, a constant, a function call, an arithmetic combination. Aggregates, Case, Subquery, Window and the database functions are all expressions, which is why they nest freely. Expressions can be used in annotate(), aggregate(), filter(), order_by(), update() and create().
F(): refer to a column
F("name") is a reference to a model field (or an annotation), evaluated by the database. It supports +, -, , /, %, * and, on bit-capable databases, bitwise operators. It lets you compare two columns, and update a row without a read-modify-write cycle that would race with other requests.
# books whose page count is more than ten times the price
Book.objects.filter(pages__gt=F("price") * 10)
# atomic increment-style update, executed as UPDATE ... SET pages = pages + 1
Book.objects.filter(title="A1").update(pages=F("pages") + 1) # -> 1 row
# on an instance: the F() is resolved by the database on save()
book = Book.objects.get(title="A1")
book.price = F("price") * Decimal("1.10")
book.save()
book.refresh_from_db()
book.price # Decimal('11.00')
Source: F() expressions and Making queries.
Two points are worth knowing. First, after save() with an F() assigned, the attribute still holds the expression on older versions, so a second save() would apply it again. Since Django 6.0 the field is refreshed from the database after save() on SQLite, PostgreSQL and Oracle (the backends with a RETURNING clause) and marked deferred on MySQL and MariaDB; on 5.2 LTS call refresh_from_db() before reusing the instance, as above. Second, F() on a character or array field can be sliced since Django 5.1.
Value(), output_field and ExpressionWrapper
Value(x) wraps a Python constant so it can take part in an expression. When an expression mixes field types, Django cannot always infer the result type and raises FieldError: Expression contains mixed types; the cure is to set output_field on the expression (or wrap it in ExpressionWrapper).
Book.objects.annotate(label=Value("book"), n=Value(1)).values("label", "n")[:1]
# [{'label': 'book', 'n': 1}]
# mixed IntegerField and DecimalField: say what the result is
price_per_page = ExpressionWrapper(
F("price") / Cast("pages", FloatField()), output_field=FloatField()
)
Book.objects.annotate(price_per_page=price_per_page).values_list("title", "price_per_page")[:2]
# [('A1', 0.0333...), ('A2', 0.1333...)]
Source: Value(), ExpressionWrapper() and Using F() with annotations.
The Cast("pages", FloatField()) matters on SQLite: with two integer-looking operands SQLite performs integer division, so a DecimalField price stored as 10 divided by 300 would give 0. PostgreSQL does numeric division and would not need the cast, which is a good reason to run the test suite against the database you deploy on (see PostgreSQL & Database Backends).
Boolean expressions and lookups as expressions
Two features remove the old "annotate, then filter on the annotation" dance. Since Django 3.0 an expression that outputs a boolean (an Exists(), a Q wrapped in ExpressionWrapper) can be used directly in filter(). Since 4.0, lookups are expressions too: GreaterThan(F("a"), F("b")) from django.db.models.lookups works in annotations, aggregations and filters.
# a Q object can be annotated directly; ExpressionWrapper makes the boolean type explicit
long_book = ExpressionWrapper(Q(pages__gt=250), output_field=BooleanField())
Book.objects.annotate(long=long_book).filter(long=True).values_list("title", flat=True)
# ['A1', 'A3', 'C1']
from django.db.models.lookups import GreaterThan
Book.objects.filter(GreaterThan(F("pages"), F("price") * 10)).count()
Source: Using F() with logical operations, Built-in expressions and the 3.0 and 4.0 release notes.
Func(): call a database function
Func is the base class of the SQL function wrappers. Used directly it calls any function by name; subclassed it makes a reusable one. The class attributes are function (the SQL name), template (the SQL pattern), arg_joiner (the separator between arguments) and arity (the exact number of arguments, enforced with a TypeError).
Book.objects.annotate(t=Func(F("title"), function="LOWER")).values_list("t", flat=True)[:2]
# ['a1', 'a2']
class Pct(Func):
arity = 2
template = "(100.0 * %(expressions)s)"
arg_joiner = " / "
Book.objects.annotate(share=Pct("pages", Value(1000))).values_list("title", "share")[:2]
# [('A1', 30), ('A2', 15)]
Pct("pages")
# TypeError: 'Pct' takes exactly 2 arguments (1 given)
Source: Func() expressions.
Never build a Func template with string formatting of untrusted input: arguments should always be passed as expressions or Value(), so the driver parameterises them. The reference page Avoiding SQL injection describes the one trap, which is passing user-controlled **extra keys to Func or an aggregate.
Writing your own aggregate
An aggregate is a Func that is allowed to appear in a GROUP BY context. Subclass Aggregate, set function, and declare capabilities with the class attributes allow_distinct, allow_order_by (6.0) and empty_result_set_value.
class GroupConcat(Aggregate):
function = "GROUP_CONCAT" # SQLite and MySQL; PostgreSQL has STRING_AGG
allow_distinct = True
output_field = CharField()
Book.objects.aggregate(formats=GroupConcat("format", distinct=True))
# {'formats': 'PB,HC,EB'}
Source: Aggregate expressions and Creating your own aggregate functions. Prefer the built-in StringAgg (6.0) over a custom backend-specific one wherever possible.
A plain Func subclass used where an aggregate is required fails with TypeError: … is not an aggregate expression; the aggregate machinery is what makes aggregate() accept it.
Conditional expressions
When() pairs a condition with a result, and Case() evaluates When objects in order and returns the first match, like SQL’s CASE WHEN … THEN … ELSE … END or an if/elif/else chain.
tier = Case(
When(price__lt=10, then=Value("budget")),
When(price__lt=30, then=Value("standard")),
default=Value("premium"),
output_field=CharField(),
)
Book.objects.annotate(tier=tier).values_list("title", "tier")
# [('A1', 'standard'), ('A2', 'standard'), ('A3', 'premium'),
# ('B1', 'standard'), ('B2', 'budget'), ('C1', 'premium')]
Source: Conditional expressions.
When accepts the same conditions as filter(): keyword lookups, Q objects and boolean expressions such as Exists() as the first positional argument; the condition argument also takes lookups since 3.2. If you omit default, unmatched rows get NULL. When the branches return different types, set output_field on the Case.
Conditional update
Case in update() changes different rows differently in one statement.
Book.objects.update(
pages=Case(When(format="EB", then=0), default=F("pages"), output_field=IntegerField())
)
# 6 rows updated; e-books get pages = 0, the others keep their value
Source: Conditional update.
Conditional aggregation
Case/When inside Sum/Count is the classic way to count by category in a single query. The shorter filter= form is equivalent and clearer; both compile to a single query (Django emits a CASE expression on databases without the FILTER clause).
Book.objects.aggregate(
cheap=Count("pk", filter=Q(price__lt=20)),
cheap_case=Sum(Case(When(price__lt=20, then=1), default=0)),
paperback_value=Sum("price", filter=Q(format="PB")),
)
# {'cheap': 3, 'cheap_case': 3, 'paperback_value': Decimal('30')}
Source: Conditional aggregation.
Conditional ordering and filtering
Case is a normal expression, so it can drive order_by(), for example to put hardcovers first; and a Q(…) | Exists(…) condition mixes a column test with a correlated test in one WHERE.
Book.objects.order_by(
Case(When(format="HC", then=0), When(format="PB", then=1), default=2), "title"
).values_list("title", "format")
# [('A2', 'HC'), ('C1', 'HC'), ('A1', 'PB'), ('B1', 'PB'), ('B2', 'PB'), ('A3', 'EB')]
Book.objects.filter(
Q(pages__gt=400) | Exists(Review.objects.filter(book=OuterRef("pk"), rating=2))
).values_list("title", flat=True)
# ['A3', 'B1', 'C1']
Source: Conditional filter.
Subquery, OuterRef and Exists
Some questions are about a related row rather than a number: "the latest review of each book", "the most expensive book of each author". They need a correlated subquery, and aggregation over a join cannot express them.
Subquery(queryset) embeds a queryset in the SELECT, WHERE or ORDER BY of another. Inside that inner queryset, OuterRef("field") refers to a column of the outer query, like F() but resolved one level up. A subquery used as a value must return exactly one column and at most one row: select the column with .values("column") and cut to one row with [:1] (after order_by).
latest = Review.objects.filter(book=OuterRef("pk")).order_by("-created")
Book.objects.annotate(latest_rating=Subquery(latest.values("rating")[:1])).values_list("title", "latest_rating")
# [('A1', 5), ('A2', 3), ('A3', 5), ('B1', 2), ('B2', None), ('C1', 4)]
top = Book.objects.filter(author=OuterRef("pk")).order_by("-price")
Author.objects.annotate(
top_title=Subquery(top.values("title")[:1]),
top_price=Subquery(top.values("price")[:1]),
).values_list("name", "top_title", "top_price")
# [('Ann', 'A3', Decimal('30')), ('Bob', 'B1', Decimal('15')), ('Cy', 'C1', Decimal('40'))]
Source: Subquery() expressions, limiting a subquery to a single column and to a single row. Subquery and OuterRef arrived in Django 1.11.
A subquery can itself aggregate, which is the correct way to compute an aggregate per row without multiplying joins. Group the inner queryset by the correlated key with values("book"), aggregate with annotate, and select the single result column. Wrap the result in Coalesce if the outer row may have no inner rows.
avg_sub = (
Review.objects.filter(book=OuterRef("pk"))
.values("book")
.annotate(a=Avg("rating"))
.values("a")
)
Book.objects.annotate(avg=Subquery(avg_sub)).values_list("title", "avg")[:3]
# [('A1', 4.5), ('A2', 3.0), ('A3', 4.666...)]
big_books = (
Book.objects.filter(author=OuterRef("pk"), pages__gt=400)
.values("author").annotate(c=Count("pk")).values("c")
)
Author.objects.annotate(n=Coalesce(Subquery(big_books), 0)).values_list("name", "n")
# [('Ann', 1), ('Bob', 0), ('Cy', 1)]
Exists
Exists(queryset) is a Subquery that returns a boolean, and it is the efficient way to ask "is there at least one related row?". It compiles to EXISTS (SELECT 1 … LIMIT 1), so the database stops at the first match instead of counting. It can be annotated, negated with ~, and, since Django 3.0, used directly in filter().
has_reviews = Exists(Review.objects.filter(book=OuterRef("pk")))
Book.objects.annotate(has_reviews=has_reviews).values_list("title", "has_reviews")
# [('A1', True), ('A2', True), ('A3', True), ('B1', True), ('B2', False), ('C1', True)]
Book.objects.filter(~has_reviews).values_list("title", flat=True) # ['B2']
Book.objects.filter(Exists(Review.objects.filter(book=OuterRef("pk"), rating=5)))
# titles 'A1' and 'A3'
SELECT ..., EXISTS(SELECT 1 AS "a" FROM "catalog_review" "U0"
WHERE "U0"."book_id" = ("catalog_book"."id") LIMIT 1) AS "has_reviews"
FROM "catalog_book"
Source: Exists() subqueries.
Pitfalls: Exists is for "any row", Subquery for "this value"; using Count(…) > 0 instead builds a join and a GROUP BY where EXISTS is a semi-join. Do not forget OuterRef, because without it the inner queryset is uncorrelated and Exists is true or false for every row at once. The older queryset.filter(relatedisnull=False).distinct() pattern also works but goes through a join and needs distinct(); an in lookup such as Author.objects.filter(pkin=Book.objects.filter(pagesgt=400).values("author")) is the right tool when you do not need correlation (['Ann', 'Cy'] in the seed data).
Window functions
An aggregate with GROUP BY collapses rows. A window function computes over a set of related rows (the "window") but keeps every row, so you can show each book next to its rank in its author’s catalogue, its predecessor’s price, or a running total.
Window(expression=…, partition_by=…, order_by=…, frame=…) adds the OVER (…) clause. partition_by splits the rows into independent groups (like GROUP BY, without collapsing), order_by orders the rows inside each partition, and frame limits how many neighbouring rows the expression sees. The expression is either a window-only function from django.db.models.functions (Rank, DenseRank, RowNumber, Lag, Lead, FirstValue, LastValue, NthValue, Ntile, CumeDist, PercentRank) or a normal aggregate (Avg, Sum, …) that is made to behave as a window.
from django.db.models.functions import DenseRank, FirstValue, Lag, Ntile, RowNumber
# rank of each book by price inside the author's catalogue
Book.objects.annotate(
rank=Window(expression=Rank(), partition_by=F("author"), order_by=F("price").desc())
).values_list("title", "author__name", "price", "rank").order_by("author__name", "rank")
# [('A3','Ann',30.00,1), ('A2','Ann',20.00,2), ('A1','Ann',10.00,3),
# ('B1','Bob',15.00,1), ('B2','Bob',5.00,2), ('C1','Cy',40.00,1)]
# the author's average price on every row, without collapsing
Book.objects.annotate(avg_author=Window(Avg("price"), partition_by=F("author")))
# previous book's price inside the author's catalogue, by publication date
Book.objects.annotate(
prev=Window(Lag("price"), partition_by=F("author"), order_by="published")
).values_list("title", "price", "prev").order_by("author", "published")
# A1 -> None, A2 -> 10, A3 -> 20, B1 -> None, B2 -> 15, C1 -> None
Source: Window functions and the window functions in the database-functions reference. Window arrived in Django 2.0; since 4.1 the order_by argument also accepts plain field names such as "published".
Rank, DenseRank, RowNumber, Ntile
RowNumber numbers rows 1, 2, 3, … with no ties. Rank gives tied rows the same number and leaves a gap afterwards; DenseRank leaves no gap. Ntile(n) splits the ordered rows into n buckets. Always give an order_by to the ranking functions; the numbering is otherwise arbitrary.
Book.objects.annotate(dr=Window(DenseRank(), order_by="format")).values_list("title", "format", "dr")
# EB -> 1, HC -> 2 (two books), PB -> 3 (three books): no gaps with DenseRank
Book.objects.annotate(q=Window(Ntile(2), order_by=F("price").desc())).values_list("title", "q")
# the three most expensive books get 1, the others 2
Frames: running totals and moving averages
A frame restricts the rows a window aggregate sees relative to the current row. RowRange(start, end) counts physical rows; ValueRange(start, end) uses the values of the ordering column. None means unbounded and 0 means the current row. A running total is "from the start to the current row"; a moving average of three rows is "one before to one after".
from django.db.models.expressions import RowRange
# running total of pages in publication order (pk breaks ties)
Book.objects.annotate(
running=Window(Sum("pages"), order_by=["published", "pk"], frame=RowRange(start=None, end=0))
).values_list("title", "pages", "running").order_by("published", "pk")
# [('C1', 500, 500), ('A1', 300, 800), ('B1', 200, 1000), ('A2', 150, 1150), ('A3', 450, 1600), ('B2', 100, 1700)]
# 3-row moving average of the price
Book.objects.annotate(
moving=Window(Avg("price"), order_by=["published", "pk"], frame=RowRange(start=-1, end=1))
)
Source: Window frames, RowRange and ValueRange. Since 5.1, RowRange accepts positive start and negative end values and both frames take an exclusion argument (a WindowFrameExclusion member) to exclude the current row, its group or its ties.
Filtering on a window
SQL does not allow WHERE on a window result, so "the top two books of each author" used to require a subquery or Python. Since Django 4.2 you can filter on a window annotation directly; Django generates the wrapping query. The restriction is that the lookups must not be disjunctive (no OR/XOR connecting a window condition) in a queryset that also aggregates.
top2 = Book.objects.annotate(
r=Window(Rank(), partition_by=F("author"), order_by=F("price").desc())
).filter(r__lte=2)
top2.values_list("title", "author__name", "r").order_by("author__name", "r")
# [('A3', 'Ann', 1), ('A2', 'Ann', 2), ('B1', 'Bob', 1), ('B2', 'Bob', 2), ('C1', 'Cy', 1)]
Source: Window functions. The 4.2 release notes: Django 4.2.
Window functions need database support, which every backend Django 6.1 supports provides; a third-party backend may not. If a combination raises NotSupportedError on your database, fall back to a subquery.
FilteredRelation
FilteredRelation puts a condition in the ON clause of a join instead of the WHERE. That is different: a LEFT OUTER JOIN filtered in ON keeps parents that have no matching children, while a WHERE on the child column would drop them. It is also the way to aggregate over a subset of a relationship while still aggregating other relationships of the same query.
# reviews rated 4 or 5, per author (nested relation books__reviews)
good = Author.objects.annotate(
good=FilteredRelation("books__reviews", condition=Q(books__reviews__rating__gte=4))
).annotate(n=Count("good"))
good.values_list("name", "n")
# [('Ann', 5), ('Bob', 0), ('Cy', 1)] <- Bob is kept, with 0
qs = Book.objects.annotate(
top=FilteredRelation("reviews", condition=Q(reviews__rating=5))
).annotate(n=Count("top"))
qs.values_list("title", "n").order_by("title")
# [('A1', 1), ('A2', 0), ('A3', 2), ('B1', 0), ('B2', 0), ('C1', 0)]
SELECT ..., COUNT("top"."id") AS "n" FROM "catalog_book"
LEFT OUTER JOIN "catalog_review" "top"
ON ("catalog_book"."id" = "top"."book_id" AND ("top"."rating" = 5))
GROUP BY ...
Source: FilteredRelation objects. FilteredRelation is Django 2.0; support for nested relations (like books__reviews) was added in 3.2.
For a simple "count the matching rows" a conditional aggregate (Count("reviews", filter=Q(reviewsrating=5))) gives the same numbers with a FILTER clause and is usually the simpler choice. Reach for FilteredRelation when you also want to select or filter by the filtered relation as a name (filter(topisnull=False)), or to join once and reuse the filtered rows in several expressions. Its documented limits are that it does not support only() or prefetch_related() on the filtered relation, nor a GenericForeignKey inherited from a parent model; a Prefetch object with its own queryset is the alternative for loading the filtered related objects.
Database functions
django.db.models.functions wraps the functions of the SQL dialect so the same Python expression runs on every backend. They are ordinary expressions: use them in annotate(), filter(), order_by(), update() and as arguments of one another. This section covers the groups with working examples; the reference lists every function.
Comparison and conversion
from django.db.models.functions import Cast, Coalesce, Greatest, Least, NullIf, Round
Book.objects.annotate(avg=Coalesce(Avg("reviews__rating"), Value(0.0))).values_list("title", "avg").order_by("title")
# [('A1', 4.5), ('A2', 3.0), ('A3', 4.67), ('B1', 2.5), ('B2', 0.0), ('C1', 4.0)]
Book.objects.annotate(
top=Greatest("price", Value(Decimal("12"))), bottom=Least("price", Value(Decimal("12")))
).values_list("title", "price", "top", "bottom")[:3]
# A1: 10.00 -> 12, 10.00 ; A2: 20.00 -> 20.00, 12 ; A3: 30.00 -> 30.00, 12
Book.objects.annotate(pages_or_null=NullIf("pages", Value(300))).values_list("title", "pages_or_null")[:2]
# [('A1', None), ('A2', 150)]
Review.objects.aggregate(r=Round(Avg("rating"), 2))
# {'r': 3.89}
Source: Comparison and conversion functions: Cast, Coalesce, Collate, Greatest, Least, NullIf.
Coalesce returns its first non-null argument and is how you turn the None of an empty aggregate into a number (before the default= argument existed, and still for non-aggregate expressions). Greatest and Least skip null arguments on PostgreSQL but return NULL if any argument is null on the other databases, as the reference notes, so wrap nullable arguments in Coalesce for portable code. Collate changes the collation for one comparison (for example NOCASE on SQLite, a custom ICU collation on PostgreSQL). The Round function takes a precision since Django 4.0.
Text
from django.db.models.functions import Concat, Left, Length, Lower, Replace, StrIndex, Substr, Upper
Author.objects.annotate(
shout=Upper("name"),
n=Length("name"),
tag=Concat("name", Value(" #"), Cast("pk", CharField())),
).values_list("shout", "n", "tag")
# [('ANN', 3, 'Ann #31'), ('BOB', 3, 'Bob #32'), ('CY', 2, 'Cy #33')]
Book.objects.annotate(
first=Left("title", 1), second=Substr("title", 2, 1),
pos=StrIndex("title", Value("1")), swapped=Replace("title", Value("A"), Value("Z")),
).values_list("first", "second", "pos", "swapped")[:2]
# [('A', '1', 2, 'Z1'), ('A', '2', 0, 'Z2')]
Source: Text functions: Chr, Concat, Left, Length, Lower, LPad, LTrim, MD5, Ord, Repeat, Replace, Reverse, Right, RPad, RTrim, SHA1 to SHA512, StrIndex, Substr, Trim, Upper.
Mixing a field and a Value in Concat needs an explicit output_field only when the field types differ; here Cast makes the primary key text. StrIndex is one-based and returns 0 when the substring is not found. Lower("title") combined with a __exact lookup is the portable case-insensitive comparison; for sorting and searching in PostgreSQL, see the collation and unaccent notes in PostgreSQL & Database Backends.
Date and time: Extract, Trunc and Now
Extract* returns one component (year, month, week day, quarter, hour, …) as an integer. Trunc* rounds a date or datetime down to a unit and keeps the type, which is what you group by to build time series. Time zone handling follows USE_TZ and the current time zone; since Django 5.0 USE_TZ is True by default.
from django.db.models.functions import ExtractQuarter, ExtractWeekDay, ExtractYear, Now, TruncDate, TruncMonth, TruncYear
Review.objects.annotate(month=TruncMonth("created")).values("month").annotate(n=Count("pk"), avg=Avg("rating")).order_by("month")
# [{'month': datetime(2026, 9, 1, 0, 0, tzinfo=UTC), 'n': 6, 'avg': 3.83},
# {'month': datetime(2026, 10, 1, 0, 0, tzinfo=UTC), 'n': 3, 'avg': 4.0}]
Book.objects.annotate(
weekday=ExtractWeekDay("published"), quarter=ExtractQuarter("published"), year=TruncYear("published")
).values_list("weekday", "quarter", "year")[:1]
# [(1, 1, datetime.date(2020, 1, 1))] <- ExtractWeekDay: Sunday is 1
Review.objects.filter(created__lte=Now()).count() # 9 - Now() is the database's clock
Source: Date functions, Extract, Trunc and Now.
ExtractWeekDay numbers Sunday as 1 whereas ExtractIsoWeekDay numbers Monday as 1; ExtractWeek is the ISO week (weeks start on Monday). TruncMonth over a DateTimeField returns an aware datetime in the current time zone, as shown. Now() is also usable as a db_default since 5.0 (see Models & Fields).
JSON and math
from django.db.models.functions import Abs, JSONArray, JSONObject, MD5, Mod, Power
Book.objects.annotate(obj=JSONObject(title="title", price="price")).values_list("obj", flat=True)[:1]
# [{'title': 'A1', 'price': 10}]
Author.objects.annotate(arr=JSONArray("name", Value(1))).values_list("arr", flat=True)[:1]
# [['Ann', 1]]
Book.objects.annotate(a=Abs(F("pages") - 250), m=Mod("pages", 7), p=Power("pages", 2)).values_list("a", "m", "p")[:2]
# [(50, 6.0, 90000.0), (100, 3.0, 22500.0)]
Source: JSON functions and Math functions. JSONObject is Django 3.2 and JSONArray is 5.2.
The math group has Abs, ACos, ASin, ATan, ATan2, Ceil, Cos, Cot, Degrees, Exp, Floor, Ln, Log, Mod, Pi, Power, Radians, Random, Round, Sign, Sin, Sqrt and Tan. Random orders unpredictably (order_by(Random())), which scans the whole table: avoid it on large tables.
UUID4 and UUID7 (new in 6.1)
Django 6.1 adds UUID4 and UUID7 as database functions, so the database itself can generate identifiers, for example as a db_default or in a bulk update. UUID7 (time-ordered, index-friendly) takes an optional shift argument that moves the embedded timestamp. Support depends on the backend:
| Backend | UUID4 | UUID7 |
|---|---|---|
PostgreSQL |
Yes ( |
PostgreSQL 18 or later. |
SQLite |
Yes. |
Only on Python 3.14 or later; raises |
MySQL, MariaDB, Oracle |
Version-dependent (MariaDB 11.7+, Oracle 23ai/26ai; not on MySQL). |
Version-dependent; check the reference. |
from django.db import NotSupportedError
from django.db.models.functions import UUID4, UUID7
Book.objects.annotate(u=UUID4()).values_list("u", flat=True)[:1]
# [UUID('507a48f7-104e-4ae2-bb2b-47babf7f671a')]
try:
Book.objects.annotate(u=UUID7()).values_list("u", flat=True)[:1]
except NotSupportedError as exc:
print(exc) # on Python 3.13 + SQLite: "UUID7 on SQLite requires Python version 3.14 or later."
Source: UUID functions and the 6.1 release notes. The example was run on SQLite with Python 3.13, which is why UUID7 raised; on PostgreSQL 18 it returns a time-ordered UUID. On older databases, keep generating UUIDs in Python with default=uuid.uuid4 and decide per project whether database-side generation is worth the version floor.
Putting it together
A leaderboard shows what the pieces buy: one query that returns, for every author, the number of books, the average rating of their books, whether they have a five-star review, and the title of their most expensive book. The annotations are built from the earlier sections and kept in a manager-like helper so the view stays thin (see Managers & Custom QuerySets).
def author_leaderboard():
priciest = Book.objects.filter(author=OuterRef("pk")).order_by("-price")
five_star = Review.objects.filter(book__author=OuterRef("pk"), rating=5)
return (
Author.objects
.annotate(
n_books=Count("books", distinct=True),
avg_rating=Coalesce(Avg("books__reviews__rating"), 0.0),
has_five_star=Exists(five_star),
priciest_title=Subquery(priciest.values("title")[:1]),
)
.order_by("-avg_rating", "name")
)
Source: Aggregation topic guide and Query expressions reference.
Note Count("books", distinct=True) next to Avg("booksreviewsrating"): both follow books, one of them goes on to reviews, so the join multiplies the book rows. distinct=True fixes the count; the average over reviews is correct because it is over the multiplied review rows themselves. This is the double-join rule from earlier, applied.
Pitfalls checklist
-
Two multi-valued aggregates in one
annotate(): adddistinct=Trueto everyCount, or use subqueries for sums and averages. -
annotate()thenfilter()on the same relation is a second join, not a restriction of the aggregate;filter()beforeannotate()restricts the aggregate. -
Meta.orderingand strayorder_by()calls add columns toGROUP BY; clear them withorder_by()in grouped queries. -
Mixed field types need an
output_field(ExpressionWrapperor theoutput_fieldargument). -
Division of integer-like columns is integer division on SQLite; cast, or test on the production database.
-
A
Subquerymust return one column and at most one row; anExistsmust be correlated withOuterRef. -
StringAggtakes aValuedelimiter, not a string;distinct=Trueon SQLite is limited. -
Database functions are not portable in every corner (
UUID7,StdDevflavours,Collatenames); check the backend notes. -
Do not aggregate in a template or a loop: compute once in the queryset and pass the numbers (see Templates).
Where the books are outdated, and what changed recently
Django 2-era books document aggregate, annotate, F, Q and Case/When correctly, but they predate several features that remove workarounds:
| Version | Change |
|---|---|
1.8 |
Query expressions, |
1.11 |
|
2.0 |
|
3.0 |
Boolean expressions ( |
3.2 |
|
4.0 |
|
4.1 |
|
4.2 |
Filtering against window functions. |
5.0 |
|
5.1 |
|
5.2 |
|
6.0 |
|
6.1 |
|
The version rows come from the Django release notes for each version, listed under References. Patterns to retire from old code and books: wrapping every Sum in Coalesce (use default=), annotating a boolean and then filtering on it (filter on the Exists or lookup expression directly), queryset.extra(select=…) and RawSQL for conditional counts (use Case/filter=), hand-written subqueries in __in for per-row values (use Subquery/OuterRef), and ranking in Python (use Window). Django itself documents extra() as a last resort.
References
-
QuerySet API reference (
aggregate,annotate,alias,FilteredRelation, aggregation functions)