Aggregations & Grouped Queries¶
Ferro aggregates with five methods on the columns themselves — count(), sum(), avg(), min(), max() — inside the same select() lambda that projects columns. There is no group_by() chainer anywhere in the API, and you will not miss it: the record shape is the grouping. Bare fields are the keys; aggregate fields are the measures.
The examples on this page use this schema:
class Account(Model):
id: int | None = Field(default=None, primary_key=True)
label: str
transactions: Relation[list["Transaction"]] = BackRef()
class Transaction(Model):
id: int | None = Field(default=None, primary_key=True)
amount: int
price: Decimal | None = Field(default=None)
memo: str
account: Annotated[
Account | None, ForeignKey(related_name="transactions")
] = None
class Account(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
label: str
transactions: Relation[list["Transaction"]] = BackRef()
class Transaction(Model):
id: Annotated[int | None, Field(default=None, primary_key=True)]
amount: int
price: Annotated[Decimal | None, Field(default=None)]
memo: str
account: Annotated[
Account | None, ForeignKey(related_name="transactions")
] = None
Global Aggregates¶
An aggregate is a method on a column reference: t.amount.sum(). Aggregate fields are user-named — they live in the dict selector form, where the key names the output field. A projection containing only aggregates collapses the whole result to exactly one record, read idiomatically with first():
# An aggregate-only projection collapses to exactly one record:
# the global aggregate, read idiomatically with first().
row = await Transaction.select(
lambda t: {
"n": t.id.count(),
"total": t.amount.sum(),
"average": t.amount.avg(),
"smallest": t.amount.min(),
"largest": t.amount.max(),
}
).first()
assert row is not None
assert row.model_dump() == {
"n": 4,
"total": 100,
"average": 25.0,
"smallest": 10,
"largest": 40,
}
Aggregates measure whatever where() leaves in — relation traversal included:
# Aggregates measure whatever where() leaves in — traversal included.
row = await (
Transaction.select(lambda t: {"total": t.amount.sum()})
.where(lambda t: t.account.label == "a1")
.first()
)
assert row is not None and row.total == 30
The source column may itself traverse (t.account.balance.avg()): the traversal narrows to rows where the relation exists, exactly like a where() predicate on the same path, and shares that path's join with any other clause that references it.
What each aggregate returns¶
Result types are a pinned cross-backend contract, derived from the source column's Python type — the same query returns the same Python types on SQLite and Postgres, even where the databases themselves disagree (Postgres widens SUM(bigint) to numeric; SQLite averages everything as a float):
| Aggregate | Source column | Result type |
|---|---|---|
count() |
any column | int — never None |
sum() |
int / float / Decimal |
the source numeric type |
avg() |
int / float |
float |
avg() |
Decimal |
Decimal — never a silently lossy float |
min() / max() |
numeric, str, datetime / date / time |
the source type, via the source codec |
# Result types are a pinned cross-backend contract derived from the
# source column: avg over a Decimal column stays Decimal (never a
# silently lossy float), on SQLite and Postgres alike.
row = await (
Transaction.select(lambda t: {"avg_price": t.price.avg()})
.where(lambda t: t.price != None) # noqa: E711
.first()
)
assert row is not None
assert isinstance(row.avg_price, Decimal)
Every aggregate result except count is T | None, because of what comes next.
Empty input: None and 0, no COALESCE¶
SQL answers an aggregate over zero rows with NULL (COUNT with 0), and Ferro passes that through verbatim — "sum of no rows" and "sum of rows totaling zero" are different facts, and zero would be the wrong identity for min/max anyway:
# Over zero matching rows, SQL's own empty-input semantics pass
# through verbatim: one record, None for sum/avg/min/max, 0 for
# count. No hidden COALESCE — "sum of no rows" and "sum of rows
# totaling zero" stay distinguishable.
row = await (
Transaction.select(
lambda t: {"n": t.id.count(), "total": t.amount.sum()}
)
.where(lambda t: t.amount > 10_000)
.first()
)
assert row is not None
assert row.n == 0
assert row.total is None
count() counts non-NULL values¶
t.price.count() is SQL's COUNT(price): it counts rows where that column is non-NULL. Count rows regardless of any column with the primary key (t.id.count()):
# COUNT(column) counts non-NULL values of that column — SQL
# semantics, verbatim. Only one of four transactions has a price...
row = await Transaction.select(
lambda t: {"rows": t.id.count(), "priced": t.price.count()}
).first()
assert row is not None
assert row.rows == 4
assert row.priced == 3
Grouped Queries: Bare Fields Are the Keys¶
Mix a plain field into an aggregate projection and the query becomes grouped — every non-aggregate field is a group key, and each group collapses to exactly one record:
# Mix a plain field in and the query becomes GROUPED: every
# non-aggregate field is a group key. There is no group_by() —
# the record shape IS the grouping.
rows = await (
Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
)
.where(lambda t: t.account_id != None) # noqa: E711
.order_by("acct")
.all()
)
assert rows.model_dump() == [
{"acct": 1, "total": 30},
{"acct": 2, "total": 30},
]
This is SQL's own rule, made unwritable to violate: SQL already requires every bare selected column to be a group key, so declaring the grouping separately could only ever restate the projection or contradict it (Postgres rejects the contradiction at runtime; SQLite silently answers with an arbitrary row's value). Deriving GROUP BY from the record shape deletes that entire error class — the dict literal is the whole story.
Group keys may traverse, and traversal narrows exactly like a predicate:
# Group keys may traverse. Traversal narrows (the account-less
# transaction drops out) exactly like a where() predicate would.
rows = await (
Transaction.select(
lambda t: {"account_label": t.account.label, "total": t.amount.sum()}
)
.order_by("account_label")
.all()
)
assert rows.model_dump() == [
{"account_label": "a1", "total": 30},
{"account_label": "b1", "total": 30},
]
"Has no relation" is a visible bucket¶
Add left_join() and relation-less rows stay in — grouped under a None key instead of silently dropped:
# left_join() keeps relation-less rows, so "has no account" becomes
# a visible None-keyed group instead of a dropped row.
rows = await (
Transaction.select(
lambda t: {"account_label": t.account.label, "total": t.amount.sum()}
)
.left_join(lambda t: t.account)
.all()
)
buckets = {row.account_label: row.total for row in rows}
assert buckets == {"a1": 30, "b1": 30, None: 40}
Zero rows, zero groups¶
A grouped query over zero matching rows returns zero records — unlike a global aggregate, there is no group to report on:
# A grouped query over zero rows returns zero records — unlike a
# global aggregate, there is no group to report on.
rows = await (
Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
)
.where(lambda t: t.amount > 10_000)
.all()
)
assert len(rows) == 0
Grouping is not partitioning¶
Grouping collapses rows: each group becomes one projected record of keys and measures, and the individual rows are gone. If you want complete model instances bucketed by a key — every transaction, arranged per account — that is a different, client-side operation over a full query:
from collections import defaultdict
by_account: dict[int | None, list[Transaction]] = defaultdict(list)
for txn in await Transaction.select().all():
by_account[txn.account_id].append(txn)
Reach for aggregation when you want facts about groups; partition in Python when you want the rows themselves, organized.
Ordering Grouped Results¶
order_by on a projected query follows SQL's own ORDER BY scoping, pinned as three rules.
Strings resolve output field names first, then root columns. order_by("total") sorts by the aggregate you named total, even if the model happens to have a column with the same name:
# The top-N idiom: keys + aggregates, order by the aggregate's
# output name, limit the groups.
top = await (
Transaction.select(
lambda t: {"account_label": t.account.label, "total": t.amount.sum()}
)
.order_by("total", "desc")
.limit(1)
.all()
)
assert len(top) == 1
That is the top-N idiom — keys plus aggregates, ordered by the measure's output name, limit() applied to the groups. limit()/offset() always act on groups in a grouped query, so pagination pages through the summary, not the underlying rows.
The lambda form spells source expressions — aggregates included. Where strings name outputs, lambdas write the expression itself:
# order_by's lambda form spells the source expression — including
# the aggregate itself.
rows = await (
Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
)
.where(lambda t: t.account_id != None) # noqa: E711
.order_by(lambda t: t.amount.sum(), "desc")
.order_by("acct")
.all()
)
assert [row.acct for row in rows] == [1, 2]
An aggregate sort key must match a projected aggregate field (same function, same column, same path); an expression you did not project is a build-time error telling you to name it in the projection.
On an aggregate projection, every sort key must be a group key or an aggregate. Sorting groups by a column that is neither is the arbitrary-row trap again — SQLite would happily pick some row's value per group — so Ferro rejects it when you build the query, whether the key is a string, a lambda, or was chained before the select():
>>> Transaction.select(lambda t: {"acct": t.account_id, "total": t.amount.sum()}).order_by("memo")
ValueError: order_by('memo') on an aggregate projection must name a group key or an aggregate: ...
On a plain (non-aggregate) projection nothing changes: unselected root columns still sort, as they always have.
The Loud Limits¶
Everything below fails when you build the query — before any SQL, with an error that names the fix:
- Source families with no portable meaning.
sum()/avg()take numeric columns (int,float,Decimal);min()/max()take orderable ones (numeric, text, date/time);count()takes anything. Enum, UUID, JSON, and bool columns are rejected —min()over a UUID does not exist on Postgres, andmax()over a native enum silently diverges between backends (definition order vs. lexical order). Where no portable meaning exists, build time is the only honest place to fail. - No aggregates in
where().WHEREfilters rows before aggregation, sowhere(lambda t: t.amount.sum() > 100)raises pointing athaving()— the post-aggregation filter, tracked in #291. Until it lands, filter rows withwhere()and compare aggregated results in Python. - The builtin-
sumtrap.sum(t.amount)(Python's builtin over a column reference) raisesdid you mean t.amount.sum()?instead of failing obscurely. - Aggregates are user-named. An aggregate outside the dict form (
select(lambda t: t.amount.sum())) raises: give it an output name. count()/exists()on an aggregate projection. "Count" is ambiguous between rows and groups, so both raise with both spellings: count matching rows with an unprojected query (Transaction.where(...).count()), count groups withlen(await q.all()).
# The loud limits, all at build time — before any SQL:
# Aggregates over families with no portable cross-backend meaning.
try:
Transaction.select(lambda t: {"x": t.memo.sum()})
except TypeError as exc:
assert "string-typed" in str(exc)
# Aggregates inside where() — filtering after aggregation is
# having(), which is not built yet.
try:
Transaction.where(lambda t: t.amount.sum() > 100)
except TypeError as exc:
assert "having()" in str(exc)
# The builtin-sum trap: aggregation is a method on the column.
try:
Transaction.select(lambda t: {"x": sum(t.amount)}) # type: ignore[arg-type]
except TypeError as exc:
assert "did you mean t.amount.sum()?" in str(exc)
# Sorting a grouped query by a column that is not a group key would
# let each group answer with an arbitrary row's value.
try:
Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
).order_by("memo")
except ValueError as exc:
assert "group key or an aggregate" in str(exc)
# count()/exists() on an aggregate projection are ambiguous between
# rows and groups; both spellings are in the error.
try:
Transaction.select(
lambda t: {"acct": t.account_id, "total": t.amount.sum()}
).count()
except ValueError as exc:
assert "len(await q.all())" in str(exc)
See Also¶
- Selecting a Column Subset — projections, traversed fields, and output aliases, which aggregations build on
- Querying Across Relationships — traversal and join semantics (ADR-0006)
- Typed Query Predicates — how aggregate expressions type