Aggregations
count, sum, avg, min, max, groupBy, having, distinct — the aggregation surface, what forge emits per dialect (GROUP BY for SQL, $group pipeline for Mongo), nullable handling, decimal precision, and the patterns for dashboard-shaped queries.
The README chapter Grouping and aggregates covers the call shape — groupBy({ by, where, _count, _sum, _avg, _min, _max, having, orderBy }). This doc is the companion that goes one layer down: every aggregate's per-dialect emit, the way _count differs from count, how having interleaves with where, the nullable and precision gotchas that show up on big tables, and the dashboard-shaped queries that exercise the whole surface.
If you have not read QUERIES.md yet, start there — this doc assumes you already know findMany, where, select, and orderBy.
Contents
- The aggregation surface
countvs_countvscountDistinctgroupBykeys — single and compositehaving— filtering aggregates_count/_sum/_avg/_min/_max— the bucket shape- Per-dialect emit table
- Nullable handling
- Decimal precision in
_sumand_avg - Date/time aggregation
- Aggregating embedded / JSON fields
wherevshaving— input filter vs output filterORDER BYan aggregate result- Pagination of aggregated results
- Window functions vs aggregations
- Performance — indexing for
GROUP BY - Mongo aggregation pipeline
- Four worked dashboards
- Cross-links
The aggregation surface
Five entry points, picked by what the caller wants back.
count({ where, distinct? })→Promise<number>. "How many rows match." It resolves to the number itself — not a wrapper object, notunknown.count({ distinct: ['col'] })has the same return type.aggregate({ where, _avg, _sum, _min, _max, _count })→ one payload object,{ _avg, _sum, _min, _max, _count }. "Overall sum / avg / min / max across these rows." Works on every dialect. It is an overload overgroupBy({ by: [] })— same IR, same SQL — that unwraps the one-element array for you, so you get the object and not[{ … }]. Forge picks this form over the Mongo pipeline form below whenever any of_count,_sum,_avg,_min,_maxis present.groupBy({ by, _avg, _sum, _min, _max, _count, where, having, orderBy, take, skip })→ array of{ <by-col>: value, _count?, _sum?, _avg?, _min?, _max? }. "Per-group aggregates."findMany({ include: { _count: { select: { <relation>: true } } } })→ rows with_count.<relation>attached. "Related-row counts per parent."aggregate({ pipeline })(Mongo-only) → raw documents from the pipeline. Escape hatch into the native$lookup/$facet/$bucketsurface.
count and groupBy are the two real workhorses — the rest is sugar or escape-hatch. All five honour the same where operator vocabulary findMany uses (see QUERIES.md → where) and the aggregate body runs after the where filter narrows the input — see where vs having.
count vs _count vs countDistinct
Three counting shapes, three different SQL forms.
Top-level count({ where }) — COUNT(*)
const n = await db.user.count({ where: { active: true } });Emits SELECT COUNT(*) FROM "user" WHERE "active" = $1 on PG / SQLite / DuckDB (with the dialect's quoting and placeholders), COUNT_BIG(*) on MSSQL, and coll.countDocuments({ active: true }) on Mongo. COUNT(*) includes rows where every column is NULL. The PG driver returns the count as a string (bigint) and forge coerces through Number() at hydration — safe up to 2^53. For tables that approach that magnitude, switch to a $queryRaw SELECT COUNT(*)::text and parse with BigInt yourself.
count({ distinct: [col] }) — COUNT(DISTINCT col)
await db.user.count({ distinct: ['email_domain'] });
// → SELECT COUNT(DISTINCT "email_domain") FROM "user"
// Mongo: [{ $group: { _id: '$email_domain' } }, { $count: 'count' }]COUNT(DISTINCT col) skips NULL values on every SQL dialect — that's the SQL standard. So this over 1,000 rows where 50 have NULL email_domain returns the number of distinct non-null domains. To count NULL as its own bucket, use coalesce("email_domain", '') through $queryRaw.
Multi-column distinct
await db.member.count({ distinct: ['org_id', 'role'] });
// → SELECT COUNT(*) FROM (SELECT 1 FROM "member" GROUP BY "org_id","role") s
// Mongo: [{ $group: { _id: { org_id: '$org_id', role: '$role' } } }, { $count: 'count' }]PG also supports SELECT COUNT(DISTINCT ("org_id","role")) natively but forge does not emit the row-value form because MySQL and SQLite don't. The subquery-then-count form is portable everywhere.
_count on a relation — per-row cardinality
_count is the only synthetic-field surface forge exposes on a read. It drops a cardinality alongside each row.
await db.user.findMany({
include: { _count: { select: { posts: true, comments: true } } },
});
// → user[0]._count = { posts: 5, comments: 12 }Per relation, that is one extra query, batched across every parent — SELECT "author_id" AS fk, COUNT(*) AS c FROM "post" WHERE "author_id" = ANY($1) GROUP BY "author_id" on Postgres (IN (?, …) where ANY is not available), $match + $group on Mongo — stamped onto row._count afterwards. (Not a correlated subquery in the SELECT list, and not a $lookup + $size; earlier revisions of this page said otherwise.) It wants an index on the relation's foreign-key column — an indexes: [{ keys: { author_id: 1 } }] entry on the model, or author_id as the leading column of a compound index. Without one, each counted relation is a sequential scan of the child table and the query collapses past a few thousand parents. See N+1.
Since 2.19.0 the count excludes soft-deleted children, so it agrees with what include returns for the same relation.
A where nested inside _count is silently ignored:
// Counts EVERY post, not just the published ones — the filter is dropped.
await db.user.findMany({
include: { _count: { select: { posts: { where: { status: 'PUBLISHED' } } } } },
});The IR carries relation counts as bare relation names, so nested arguments are discarded at build time. This page previously documented the filtered form as working; it does not. Use a groupBy over the children with the filter and join in memory:
const published = await db.post.groupBy({
by: ['author_id'],
where: { author_id: { in: userIds }, status: 'PUBLISHED' },
_count: { _all: true },
});count accepts take / skip / cursor
count honours the same pagination args as findMany. The common use is "is there a 21st row?" without materialising the page:
const more = await db.post.count({ where: { author_id: meId }, take: 21, skip: 20 });
// more === 1 means there's at least one row past page 1 of 20Emits SELECT COUNT(*) FROM (SELECT 1 FROM "post" WHERE … LIMIT 21 OFFSET 20). Don't reach for it when the answer is "the total" — drop the pagination args for that.
groupBy keys — single and composite
by accepts an array of one or more own-scalar column names. Each distinct combination becomes one result row.
Single key
await db.order.groupBy({
by: ['status'],
_count: { _all: true },
_sum: { total: true },
});
// → [{ status: 'PAID', _count: { _all: 17 }, _sum: { total: 12_300 } }, … ]PG / SQLite / DuckDB emit:
SELECT "status", COUNT(*) AS "__agg_count__all", SUM("total") AS "__agg_sum_total"
FROM "order" GROUP BY "status";MySQL emits the same with backtick quoting; MSSQL uses COUNT_BIG(*) and square-bracket quoting; Mongo emits [{ $group: { _id: { status: '$status' }, __agg_count__all: { $sum: 1 }, __agg_sum_total: { $sum: '$total' } } }].
The __agg_<bucket>_<field> aliases are wire-stable — the executor re-nests them into the { _count: { _all }, _sum: { total } } shape the caller sees. The aliases are an implementation detail; you never see them unless you $queryRaw the same table.
Composite keys
Pass more than one column. Each unique tuple is one bucket.
await db.order.groupBy({
by: ['status', 'currency'],
_sum: { total: true },
_count: { _all: true },
});
// → [
// { status: 'PAID', currency: 'NGN', _sum: { total: 12_300 }, _count: { _all: 17 } },
// { status: 'PAID', currency: 'USD', _sum: { total: 4_200 }, _count: { _all: 9 } },
// …
// ]GROUP BY "status", "currency" on SQL. On Mongo the _id becomes a nested document { status, currency } and the executor unwraps it back into top-level keys at hydration.
The number of groups is bounded by the cardinality of the columns. (status, currency) over a few hundred enum values is fine; (user_id, day) over a year of activity is a few hundred thousand groups and is still fine on a properly-indexed table; (session_id, …) over a billion-row event table is where the planner spills to disk. See Performance.
Grouping by a derived value
Forge does not emit GROUP BY <expression> — the by list takes column names only. Date-bucket grouping (per day, per week) is the common case; see Date/time aggregation for the two portable workarounds (a generated column at DDL time, or $queryRaw).
having — filtering aggregates
having filters groups after the aggregate. Two equivalent shapes both compile to the same IR — forge's normalizeHaving flips field-first into bucket-first before the dialect compilers see it.
// field-first (Prisma's surface)
having: { total: { _sum: { gte: 120 } } }
// bucket-first (forge's IR shape)
having: { _sum: { total: { gte: 120 } } }The vocabulary is the same six comparison operators that where exposes: equals, not, lt, lte, gt, gte. Anything richer (in, contains, between) is not honoured — the IR builder silently drops unrecognised ops. Use $queryRaw for richer HAVING predicates (HAVING SUM("total") BETWEEN $1 AND $2).
Per-dialect emit
having: { _sum: { total: { gt: 1_000 } }, _count: { _all: { gte: 5 } } }Emits HAVING SUM("total") > $1 AND COUNT(*) >= $2 on PG / SQLite / DuckDB (with dialect quoting and placeholders); COUNT_BIG(*) on MSSQL; { $match: { __agg_sum_total: { $gt: 1000 }, __agg_count__all: { $gte: 5 } } } on Mongo after the $group stage.
Multiple having clauses are joined with AND on SQL and a single $match document on Mongo. There is no having: { OR: [...] } today — nest the predicate logic in a $queryRaw if you need disjunction across aggregate buckets.
_count._all in having
having: { _count: { _all: { gt: 0 } } }_all is the magic key that means "all rows in the group", i.e. COUNT(*). Per-column counts in having target the column directly: _count: { email: { gt: 0 } } → HAVING COUNT("email") > $1. Recall that COUNT(col) skips NULLs, so _count: { email: { gt: 0 } } is "at least one non-null email in the group", not "at least one row".
_count / _sum / _avg / _min / _max — the bucket shape
Each bucket key maps directly to its SQL function — _count → COUNT(col) or COUNT(*) for _all, _sum → SUM, _avg → AVG, _min → MIN, _max → MAX. Mongo uses $sum / $avg / $min / $max on the field, with { $sum: 1 } for _all and a $cond wrapper that skips NULLs for per-column counts (matching SQL's NULL-skip behaviour). _sum returns NULL on all-null input on SQL but 0 on Mongo — see Nullable handling. _avg is floating-point — see Decimal precision.
The call-site shape is the same across every bucket: a record from column name to true. Pass true to opt the column in.
await db.order.groupBy({
by: ['status'],
_count: { _all: true, customer_id: true }, // COUNT(*) AND COUNT("customer_id")
_sum: { total: true, tax: true }, // SUM("total"), SUM("tax")
_avg: { total: true },
_min: { total: true },
_max: { total: true },
});Each bucket key surfaces on the return row only if you asked for it. The shape is not flattened — _sum.total stays under _sum, not under the bucket name's prefix. Destructure on the caller side:
for (const row of result) {
const { status, _sum, _count, _avg } = row;
console.log(status, _sum.total, _count._all, _avg.total);
}The unbucketed aggregate form
For "what's the overall sum across this filter", pass no by:
await db.order.aggregate({
where: { status: 'PAID', created_at: { gte: monthStart } },
_sum: { total: true, items_count: true },
_avg: { total: true },
_min: { total: true },
_max: { total: true },
_count: { _all: true }, // COUNT(*) — the matched row count
});
// → { _sum: { total: 19_872, items_count: 187 },
// _avg: { total: 432.18 },
// _min: { total: 12 }, _max: { total: 4500 },
// _count: { _all: 46 } }The return value is the payload object itself, so read straight off it — result._sum.total, result._count._all. There is no array to index into; that is the one difference from groupBy({ by: [] }), which is otherwise the same query.
Every bucket is a map of fields, never a bare true. Use _count: { _all: true } for COUNT(*), and _count: { total: true } for COUNT("total") — the count of rows where that column is not NULL.
A bare true is refused when the query is built, on every dialect and for all five buckets:
await db.order.groupBy({ by: ['status'], _count: true });
// [forge] groupBy on 'orders': _count takes a map of fields, not true.
// Use `_count: { _all: true }` for the row count, or `_count: { <field>: true }`.The guard is new in 2.18.0, and it exists because the old failure was hard to read. A bare bucket contributed zero SELECT columns, so with another bucket present the key was simply missing from the result, and on its own the statement came out as SELECT FROM "orders" … and the driver reported a syntax error near FROM — pointing at the one part of the query that was fine. The check runs at IR-build time rather than in the type system, so it catches an untyped caller too.
Per-dialect emit (PG):
SELECT
SUM("total") AS "__agg_sum_total",
SUM("items_count") AS "__agg_sum_items_count",
AVG("total") AS "__agg_avg_total",
MIN("total") AS "__agg_min_total",
MAX("total") AS "__agg_max_total",
COUNT(*) AS "__agg_count__all"
FROM "order"
WHERE "status" = $1 AND "created_at" >= $2;No GROUP BY — there is exactly one row in the result. On Mongo the $group stage uses _id: null and the executor unwraps the single document back into the { _sum, _avg, … } payload.
Zero matching rows
When the where matches nothing, aggregate still returns the shape you asked for rather than undefined or {}. Counts come back 0; every _sum, _avg, _min and _max comes back null.
await db.order.aggregate({
where: { status: 'NOPE' },
_sum: { total: true },
_avg: { total: true },
_count: { _all: true },
});
// → { _count: { _all: 0 }, _avg: { total: null }, _sum: { total: null } }That guarantee is why destructuring the result cannot throw. What it does not do is hand you a number: null is not 0, and the difference is invisible until a filter goes empty in production. A dashboard tile that does _sum.total.toFixed(2) throws Cannot read properties of null (reading 'toFixed') on the first empty month; one that interpolates ${_sum.total} prints the string "null". JavaScript hides it in arithmetic — null + 1 is 1 — so the bug survives every test that only exercises a non-empty table. Coerce at the read:
const { _sum, _count } = await db.order.aggregate({
where: filter, _sum: { total: true }, _count: { _all: true },
});
const revenue = (_sum.total ?? 0).toFixed(2); // "0.00" on an empty filter
const orders = _count._all; // already 0, no coerce neededThis is a different cause from the all-NULL-column case in Nullable handling — there the rows exist and SQL's own SUM of all-NULL input is what returns NULL. Both land on null, so the same ?? 0 covers both.
Per-dialect emit table
The query is groupBy({ by: ['status'], _sum: { total: true }, _count: { _all: true }, having: { _sum: { total: { gt: 1000 } } }, orderBy: { _sum: { total: 'desc' } }, take: 10 }).
PG / SQLite / DuckDB:
SELECT "status", SUM("total") AS "__agg_sum_total", COUNT(*) AS "__agg_count__all"
FROM "order"
GROUP BY "status"
HAVING SUM("total") > $1
ORDER BY SUM("total") DESC
LIMIT 10;MySQL emits the same shape with backtick quoting and ? placeholders. MSSQL puts the row cap into TOP 10 at the head and uses COUNT_BIG(*) — COUNT(*) on MSSQL returns int and overflows at 2.1 billion rows. MySQL is the one to watch on legacy installs: ONLY_FULL_GROUP_BY became the default in 5.7.5 and older installs allow SELECT a, b, SUM(c) GROUP BY a with b undefined. Forge always lists every by column in both clauses, so the legacy footgun is a non-issue at the API layer.
Mongo:
[
{ $group: { _id: { status: '$status' }, __agg_sum_total: { $sum: '$total' }, __agg_count__all: { $sum: 1 } } },
{ $match: { __agg_sum_total: { $gt: 1000 } } },
{ $sort: { __agg_sum_total: -1 } },
{ $limit: 10 },
]$group → $match → $sort → $limit mirrors WHERE → GROUP BY → HAVING → ORDER BY → LIMIT exactly. The Mongo planner cannot push the post-$group $match back into the pre-$group filter the way SQL planners push HAVING into WHERE — so the index choice for the $group stage is on the grouping key, not the filter columns. See Performance.
Nullable handling
The single most-asked-about gotcha. SQL aggregate functions and Mongo aggregate operators handle NULL slightly differently, and the answers differ from findMany's.
COUNT(*) vs COUNT(col)
COUNT(*) counts every row (even all-NULL rows). COUNT(col) counts rows where col is NOT NULL. COUNT(DISTINCT col) counts distinct non-null values. The forge mapping:
db.user.count({}) // COUNT(*)
db.user.groupBy({ by:['org_id'], _count: { email: true } }) // COUNT("email") — non-null
db.user.count({ distinct: ['email'] }) // COUNT(DISTINCT "email") — distinct non-nullThe per-column form is the trap. _count: { email: true } returns the count of rows in the group with a non-null email — if your model has 100 users in an org and 30 have no email, that bucket reads 70, which looks wrong when the caller expected the group's row count. For "every row in the group", use _count: { _all: true }.
SUM over all-NULL input
SQL standard: SUM(col) returns NULL on all-NULL input, not 0. PG, MySQL, SQLite, DuckDB, MSSQL all match this. In the forge result, that surfaces as _sum: { revenue: null }. Mongo's $sum is the outlier: empty / all-null input yields 0, not null.
const row = await db.order.aggregate({ where: { status: 'CANCELLED' }, _sum: { revenue: true } });
const total = row._sum.revenue ?? 0; // explicit zero-coerce — portable everywhereThis is one of the few places the same forge call returns a different scalar across dialects. Pin it with the ?? 0 coerce and you get the SQL-style "I asked for a sum, I got a number" surface everywhere.
AVG, MIN, MAX over all-NULL input
All three return null (SQL) or null (Mongo) on empty / all-null input. There is no 0 divergence here — AVG of an empty set is genuinely undefined and SQL is right to say so. Coerce at the call site if your dashboard wants a "no data" placeholder.
GROUP BY on a nullable column
Every dialect groups NULL into its own bucket — one row with _id: null on Mongo, one row with the column being NULL on SQL.
await db.order.groupBy({ by: ['referrer_id'], _count: { _all: true } });
// → [{ referrer_id: null, _count: { _all: 42 } }, { referrer_id: 'u1', ...}, … ]That's standard SQL behaviour and almost always what the caller wants. If you want to exclude the NULL bucket, add where: { referrer_id: { not: null } }.
Decimal precision in _sum and _avg
Floats are not associative — (a + b) + c and a + (b + c) can land on different bits when a and b are large and c is small. SUM(col) accumulates left-to-right and the planner is free to reorder under a hash aggregate. The visible drift on a sum of a million floating-point dollar amounts is on the order of cents to single dollars; on an _avg of the same, much smaller.
For money, declare the column as f.decimal({ precision, scale }):
const Order = model('order', {
id: f.id(),
total: f.decimal({ precision: 18, scale: 4 }),
});Per-dialect storage: PG / MySQL / DuckDB store NUMERIC(18, 4) / DECIMAL(18, 4) as arbitrary-precision and SUM stays exact; MSSQL widens to DECIMAL(38, 4) to avoid overflow; SQLite stores as TEXT and aggregates promote to float unless you ship the SQLite decimal extension; Mongo's Decimal128 is IEEE 754-2008 decimal and $sum widens up to 34 significant digits.
The PG driver returns NUMERIC as a string; forge's hydrator coerces to JavaScript number (a double, so precision is lost on the way out). For exact decimal arithmetic, pull the raw string through $queryRaw and parse with decimal.js or big.js in the caller. On Mongo, Decimal128 survives the round trip if your driver preserves the BSON type — see Mongo.
AVG widening
AVG of an integer column returns a float on PG (numeric) and a float on Mongo. SQLite's AVG widens to REAL — the IEEE 754 double. For an integer-only running average, do the math yourself:
const { _sum, _count } = await db.order.aggregate({ _sum: { items_count: true }, _count: { _all: true } });
const avgItems = _count._all > 0 ? _sum.items_count / _count._all : 0;That's exact integer division on the way in and one float divide at the end — no accumulation error.
Date/time aggregation
The classic dashboard query: revenue per day for the last 30 days. Forge does not expose a portable date-bucket operator in the by list — GROUP BY date_trunc('day', "created_at") is per-dialect SQL.
Three portable workarounds, in order of preference.
1. Generated column at DDL time
Declare a stored-generated column for the bucket and group on that. Works on PG, MySQL 5.7+, SQLite 3.31+, MSSQL, DuckDB. Mongo doesn't need it — see option 3.
ALTER TABLE "order"
ADD COLUMN "created_day" date GENERATED ALWAYS AS (("created_at")::date) STORED;
CREATE INDEX "order_created_day_idx" ON "order"("created_day");Then forge groups portably:
await db.order.groupBy({
by: ['created_day'],
where: { created_at: { gte: thirtyDaysAgo } },
_sum: { total: true },
});The generated column is computed on insert and indexable. Query plans match a plain integer-keyed dashboard.
2. $queryRaw with the dialect's truncation function
// Postgres
const rows = await db.$queryRaw<Array<{ day: Date; total: number }>>`
SELECT date_trunc('day', "created_at") AS day, SUM("total") AS total
FROM "order"
WHERE "created_at" >= ${thirtyDaysAgo}
GROUP BY day ORDER BY day ASC;
`;Per-dialect day/week/month equivalents: PG and DuckDB use date_trunc('day' | 'week' | 'month', col); MySQL uses DATE(col), YEARWEEK(col, 1), DATE_FORMAT(col, '%Y-%m-01'); SQLite uses date(col), strftime('%Y-W%W', col), strftime('%Y-%m-01', col); MSSQL uses CAST(col AS date), DATEPART(week, col) + year, DATEFROMPARTS(YEAR(col), MONTH(col), 1). Wrap the per-dialect SQL in a helper if your app targets more than one — the abstraction is yours to own, not the ORM's.
3. Mongo $dateTrunc
Mongo 5.0+ has $dateTrunc, which fits inside an aggregate pipeline cleanly. Reach for the raw db.<model>.aggregate({ pipeline }) surface:
await db.order.aggregate({
pipeline: [
{ $match: { created_at: { $gte: thirtyDaysAgo } } },
{ $group: {
_id: { $dateTrunc: { date: '$created_at', unit: 'day' } },
total: { $sum: '$total' },
} },
{ $sort: { _id: 1 } },
],
});On Mongo 4.x, swap for $dateToString: { format: '%Y-%m-%d', date: '$created_at' } in the _id — string buckets sort lexicographically the same as the underlying dates as long as the format is ISO.
Aggregating embedded / JSON fields
groupBy's by list takes own-scalar column names — not JSON paths. For GROUP BY data->'tier', drop to $queryRaw (SQL) or the raw aggregate pipeline (Mongo). The per-dialect read syntax — "data"->>'tier' on PG, JSON_UNQUOTE(JSON_EXTRACT(...)) on MySQL, json_extract on SQLite/DuckDB, JSON_VALUE on MSSQL — is the same syntax forge uses for the path operator inside where; see JSON-PATH.md for the full table.
// Postgres
await db.$queryRaw<Array<{ tier: string; n: number }>>`
SELECT "data"->>'tier' AS tier, COUNT(*) AS n
FROM "user" GROUP BY tier;
`;
// Mongo — dotted path inside $group._id
await db.user.aggregate({
pipeline: [{ $group: { _id: '$data.tier', n: { $sum: 1 } } }],
});When the JSON path is read often, lift it to a top-level scalar column — a generated column on PG / MySQL, or a write-side hook on every dialect. The dashboard gets indexed-column performance and the JSON-path surface stays the escape hatch for ad-hoc queries.
where vs having — input filter vs output filter
The mental model: where is "which rows go into the bucket", having is "which buckets come out the other side".
await db.order.groupBy({
by: ['status'],
where: { created_at: { gte: monthStart } }, // rows from this month only
_sum: { total: true },
having: { _sum: { total: { gt: 10_000 } } }, // only groups whose monthly sum > 10k
});On SQL that's WHERE "created_at" >= $1 … GROUP BY "status" HAVING SUM("total") > $2; on Mongo, two $match stages bracket the $group. If the predicate references a non-aggregate column, put it in where — the input narrows before the group, fewer rows scan, any index on the predicate column kicks in. If it references an aggregate, it has to be in having because the value doesn't exist until after the group.
A common mistake: having: { status: { equals: 'PAID' } } when you meant where: { status: 'PAID' }. The having form is silently dropped (forge's IR ignores unrecognised buckets) and the filter never applies — every status comes through and the total is wrong. The keys in having must be aggregate buckets (_count, _sum, _avg, _min, _max), not column names. Field-first (having: { total: { _sum: { gt: 1 } } }) is accepted because _sum is the inner key; the IR normaliser flips it. Bucket-less (having: { total: { gt: 1 } }) is silently dropped — spell out the bucket.
ORDER BY an aggregate result
Sort by a bucket key with the same orderBy shape findMany uses, with the aggregate bucket as the field path.
orderBy: { _sum: { total: 'desc' } }
// or: orderBy: [{ _sum: { total: 'desc' } }, { status: 'asc' }]On SQL this emits the aggregate expression, not the SELECT-list alias:
ORDER BY SUM("orders"."total") DESC, "orders"."customer_id" ASC_count: { _all: 'desc' } becomes ORDER BY COUNT(*) DESC. Emitting the expression rather than the alias is deliberate: it means you can sort on a bucket you did not also select, which an alias reference cannot do.
// order by average order value, without returning it
await db.order.groupBy({
by: ['customer_id'],
_count: { _all: true },
orderBy: { _avg: { total: 'desc' } },
});
// → SELECT "orders"."customer_id", COUNT(*) AS "__agg_count__all"
// FROM "orders" GROUP BY "orders"."customer_id"
// ORDER BY AVG("orders"."total") DESCOn Mongo the sort targets the $group output alias, which exists by then: { $sort: { __agg_sum_total: -1 } }.
Order on a by column directly with orderBy: { status: 'asc' }. On Mongo the by columns live under _id.<name>, which the compiler resolves automatically — a mixed sort comes out as { $sort: { __agg_sum_total: -1, '_id.customer_id': 1 } }.
This is fixed in 2.18.0, and it was broken in a quiet way before.buildOrderBy had no branch for an aggregate bucket, so { _sum: { total: 'desc' } } was dropped from the IR entirely and no ORDER BY on it was emitted. A mixed sort was the worst case: the plain-column entry survived and the aggregate entry did not, so the query came back ordered — just not by the thing you asked for. "Top ten customers by lifetime value" returned an arbitrary ten rows. If you are below 2.18.0, check any groupBy you sort by a bucket.
An aggregate orderBy is only meaningful on groupBy. A plain findMany drops the entry rather than emitting "orders"."_sum", which would be a SQL error.
Pagination of aggregated results
groupBy honours take (alias limit) and skip (alias offset) — both compile to LIMIT/OFFSET on SQL and $limit/$skip on Mongo.
await db.order.groupBy({
by: ['customer_id'],
_sum: { total: true },
orderBy: [{ _sum: { total: 'desc' } }, { customer_id: 'asc' }],
take: 20,
skip: 40,
});The same offset-based caveat from QUERIES.md → pagination applies: if a new order arrives between page loads, the group sums shift and the page boundary moves. For a live dashboard where the aggregate is changing under you, the right answer is usually to materialise the aggregate into a snapshot table (or a materialised view) and paginate over that — see BACKEND.md for the snapshot pattern.
Cursor pagination on a grouped result is harder than on a plain findMany because the visible sort key (the aggregate) is not unique. Two patterns work: cursor on (sum, group_key) with the group key as a tie-breaker, and implement the cursor advance yourself via where: { OR: [ { sum: { lt: lastSum } }, { sum: lastSum, customer_id: { gt: lastKey } } ] } — groupBy has no built-in cursor surface today. Or cap the result: if the dashboard only shows the top 100, skip pagination entirely and let the UI virtualise.
Window functions vs aggregations
groupBy collapses to one row per group; window functions keep every row and attach an aggregate computed across a window. Running totals, ranks, moving averages, and top-N-per-group are all window-function shapes. The typed aggregate surface on this page does not emit them — groupBy always compiles to GROUP BY — so on SQL reach for $queryRaw:
// top 3 most-expensive orders per customer
const rows = await db.$queryRaw<Array<{ id: string; customer_id: string; total: number; rk: number }>>`
SELECT id, customer_id, total,
ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) AS rk
FROM "order" WHERE "status" = 'PAID';
`;
const top3 = rows.filter((r) => r.rk <= 3);Per-dialect support: PG, MySQL 8+, SQLite 3.25+, MSSQL, and DuckDB all support window functions; MySQL 5.7 does not; Mongo's $setWindowFields (5.0+) runs through the aggregate({ pipeline }) surface rather than raw SQL. WINDOWS.md is the full treatment — the per-dialect matrix, top-N-per-group, moving averages and sessionization. See also RAW-SQL.md.
Performance — indexing for GROUP BY
The single biggest lever on aggregate latency is whether the by column (or the leading edge of a compound index on it) is indexed.
SQL plans
With a B-tree on the by column, the planner uses an index-only scan (PG) or range scan (MySQL/SQLite) and groups in order — linear in the number of distinct buckets. Without one, it falls back to a sequential scan plus a hash aggregate (PG / DuckDB) or sort-then-group (MySQL / SQLite) — linear in the row count.
For groupBy({ by: ['status'], _sum: { total: true } }) on PG over 1M rows: ~120 ms with Seq Scan → HashAggregate, ~12 ms with Index Only Scan → GroupAggregate. The 10× gap holds across PG / MySQL / MSSQL / DuckDB. SQLite uses sort-then-group by default; the index turns the sort into a no-op.
Composite indexes and filter-before-group
For composite by, the index has to match the leading edge: a (status, currency) index serves by: ['status'] and by: ['status', 'currency'] equally well, but not by: ['currency'] alone — same rule as where. The combined (where_col, by_col) index is the optimal shape when both clauses are present:
const Order = model('order', {
id: f.id(), org_id: f.string(), status: f.string(), total: f.int(),
}, {
indexes: [{ keys: { org_id: 1, status: 1 } }], // covers per-org GROUP BY status
});groupBy({ by: ['status'], where: { org_id }, _sum: { total: true } }) uses the compound index for both the WHERE and the GROUP BY, no sort.
Mongo
Mongo's $group cannot use an index for the grouping itself — the $group stage always scans every document the $match lets through. The index only helps the $match. The optimisation lever is:
- Put the most-selective
$matchfirst. - Index the
$matchfilter columns. - Cap the result with
$limitwhen possible (after$sort).
For aggregations that run on every page load, materialise the result with $merge or $out into a rollup collection and read that directly. See MATERIALIZED-VIEWS.md for the $merge / $out shape and the refresh strategies.
Mongo aggregation pipeline
db.<model>.aggregate({ pipeline }) is the escape hatch into Mongo's native pipeline — every stage the driver supports, with forge's extended-JSON coercion ({ $oid: 'abc' } → native ObjectId) and a post-processor that stringifies ObjectId and remaps top-level _id to id (see src/__tests__/aggregate-output.spec.ts).
How groupBy translates
| forge call | Pipeline stage |
|---|---|
where | $match before $group |
by | $group._id |
_count: { _all: true } | __agg_count__all: { $sum: 1 } |
_count: { col: true } | per-column non-null count |
_sum/_avg/_min/_max | $sum / $avg / $min / $max on $col |
having | $match after $group on the __agg_* aliases |
orderBy / take / skip | $sort / $limit / $skip |
When to drop to the raw pipeline
Reach for aggregate({ pipeline }) directly when you need a $lookup (Mongo's JOIN — groupBy cannot pull related-collection columns), a $facet for a multi-panel dashboard (see worked example (d) below), a $bucket / $bucketAuto histogram, $dateTrunc in the group key, or a $setWindowFields window-function stage.
// Customers with order count, lifetime value, average order — single round-trip
const rows = await db.user.aggregate({
pipeline: [
{ $match: { active: true } },
{ $lookup: {
from: 'order', localField: '_id', foreignField: 'customer_id', as: 'orders',
pipeline: [{ $match: { status: 'PAID' } }],
} },
{ $project: {
name: 1,
n: { $size: '$orders' },
lifetime_value: { $sum: '$orders.total' },
avg_order: { $avg: '$orders.total' },
} },
{ $sort: { lifetime_value: -1 } },
{ $limit: 50 },
],
});Four worked dashboards
(a) Revenue per day for the last 30 days
The bread-and-butter dashboard widget. Date-bucketed sum with a stable sort.
PG / MySQL / SQLite / DuckDB / MSSQL — with a generated created_day column on the table:
const rows = await db.order.groupBy({
by: ['created_day'],
where: { created_at: { gte: thirtyDaysAgo }, status: 'PAID' },
_sum: { total: true },
_count: { _all: true },
orderBy: { created_day: 'asc' },
});
// → [{ created_day: Date('2026-05-26'), _sum: { total: 12_300 }, _count: { _all: 17 } }, …]Without the generated column, use $queryRaw with the dialect's date_trunc:
const rows = await db.$queryRaw<Array<{ day: Date; total: number; n: number }>>`
SELECT date_trunc('day', "created_at") AS day,
SUM("total") AS total,
COUNT(*) AS n
FROM "order"
WHERE "created_at" >= ${thirtyDaysAgo} AND "status" = 'PAID'
GROUP BY day
ORDER BY day ASC;
`;Mongo:
const rows = await db.order.aggregate({
pipeline: [
{ $match: { created_at: { $gte: thirtyDaysAgo }, status: 'PAID' } },
{ $group: {
_id: { $dateTrunc: { date: '$created_at', unit: 'day' } },
total: { $sum: '$total' },
n: { $sum: 1 },
} },
{ $sort: { _id: 1 } },
],
});For a zero-fill (days with no orders show up as total: 0), synthesise the missing days in the caller — neither SQL nor Mongo will emit rows for buckets with zero matching input.
(b) Top 10 customers by lifetime value
const top = await db.order.groupBy({
by: ['customer_id'],
where: { status: 'PAID' },
_sum: { total: true },
_count: { _all: true },
orderBy: { _sum: { total: 'desc' } },
take: 10,
});
// → [{ customer_id: 'u_42', _sum: { total: 89_400 }, _count: { _all: 124 } }, …]The rows really are the top ten: the orderBy compiles to ORDER BY SUM("order"."total") DESC before the LIMIT 10, so the cap is applied to the sorted groups. Needs 2.18.0 — before that the aggregate orderBy was dropped and this query returned an arbitrary ten customers, which looks identical in a dashboard.
To attach the customer name, follow up with a second findMany against id: { in: top.map(r => r.customer_id) } and zip into a Map. The alternative is a $queryRaw JOIN on SQL or a $lookup on Mongo — both pay one extra scan to save the second round-trip, which is rarely worth it for a 10-row top-N dashboard.
(c) Histogram of order amounts
PG / SQLite / DuckDB ship width_bucket(value, min, max, count) — 1-indexed bucket the value falls into, with one extra bucket above max. MySQL doesn't have it; emulate with FLOOR(total / 500).
const hist = await db.$queryRaw<Array<{ bucket: number; n: number }>>`
SELECT width_bucket("total", 0, 10000, 20) AS bucket, COUNT(*) AS n
FROM "order" WHERE "status" = 'PAID'
GROUP BY bucket ORDER BY bucket;
`;Mongo's $bucket (with explicit boundaries) and $bucketAuto (forge picks them) are the native shape:
const hist = await db.order.aggregate({
pipeline: [
{ $match: { status: 'PAID' } },
{ $bucket: {
groupBy: '$total',
boundaries: [0, 500, 1000, 2500, 5000, 10000, Infinity],
default: 'over',
output: { n: { $sum: 1 } },
} },
],
});(d) Mongo $facet — multi-dimensional admin dashboard
One query, four panels, one shared $match scan.
const dashboard = await db.order.aggregate({
pipeline: [
{ $match: { created_at: { $gte: monthStart } } },
{ $facet: {
byStatus: [
{ $group: { _id: '$status', total: { $sum: '$total' }, n: { $sum: 1 } } },
],
topCustomers: [
{ $group: { _id: '$customer_id', total: { $sum: '$total' } } },
{ $sort: { total: -1 } },
{ $limit: 10 },
],
daily: [
{ $group: { _id: { $dateTrunc: { date: '$created_at', unit: 'day' } }, total: { $sum: '$total' } } },
{ $sort: { _id: 1 } },
],
totals: [
{ $group: { _id: null, n: { $sum: 1 }, gross: { $sum: '$total' }, avg: { $avg: '$total' } } },
],
} },
],
});
// → [{ byStatus: [...], topCustomers: [...], daily: [...], totals: [...] }]On SQL the equivalent is four separate groupBy calls — or one $queryRaw with four CTEs. For a dashboard that loads on every navigation the single-round-trip pattern pays off; for one-shot exports the cost is roughly the same.
Cross-links
- QUERIES.md —
findMany,where,select,orderBy, pagination,count, the_countsynthetic field - JSON-PATH.md — JSON path reads, used inside aggregations for grouping on embedded scalars
- MONGO.md — Mongo adapter specifics, including
Decimal128, materialised aggregates via$merge/$out, and the rest of the pipeline surface - RAW-SQL.md —
$queryRawand$executeRawfor window functions, recursive CTEs, dialect-specific aggregate functions - N-PLUS-ONE.md — when
_counton a relation degrades to a per-parent subquery - DUCKDB.md — DuckDB's columnar planner for analytical aggregates
- INDEXES.md — composite indexes that cover both
whereandby - BACKEND.md — materialised aggregates and rollup tables for dashboards that read every request