Aggregates and grouping
Totals, counts and groups in the select list, and buckets of time.
Put count, sum, avg,
min, max, first or last in
the select list to fold the rows a query finds into one, and add
group for one row per distinct set of keys.
Run in the shell, answers and all; the timings are from one run.
fenec> create collection sales (region text, qty int, price int, at timestamp);
collection `sales` created
fenec> put sales [{region: "north", qty: 2, price: 500, at: "2026-05-03T09:10:00Z"}, {region: "north", qty: 1, price: 900, at: "2026-05-03T09:40:00Z"}, {region: "south", qty: 4, price: 300, at: "2026-05-03T10:05:00Z"}, {region: "south", qty: 3, price: 350, at: "2026-05-03T10:50:00Z"}, {region: "north", qty: 5, price: 400, at: "2026-05-03T11:15:00Z"}];
5 records (196.33µs)
fenec> get sales select count(*) as n, sum(qty) as units, max(price) as top
n units top
- ----- ---
5 15 900
(1 rows, 152.33µs)
fenec> get sales select region, count(*) as n, sum(qty * price) as revenue group region order revenue desc
region n revenue
------ - -------
north 3 3900
south 2 2250
(2 rows, 171.04µs)
fenec> get sales select bucket(at, 1h) as hour, sum(qty) as units group hour
hour units
-------------------- -----
2026-05-03T09:00:00Z 3
2026-05-03T10:00:00Z 7
2026-05-03T11:00:00Z 5
(3 rows, 149.96µs)
fenec> get sales select region, sum(qty) as units group region having sum(qty) >= 8
region units
------ -----
north 8
(1 rows, 168.46µs)An aggregate folds any expression, sum(qty * price);
bucket(at, 1h) truncates a timestamp to the hour it falls in, and
its intervals and time zones are under Functions
over a row; and having keeps the groups it holds for. Bars, VWAP, distinct users
and funnels are on Analytics and market data.
Aggregates
Put count(*), count(…), count(distinct …),
sum, avg, min, max,
first or last in the select list to get totals.
Add group for one row per distinct set of its keys' values;
a list of the keys alone, select city group city, answers each
set once, as SQL's select distinct city does.
get orders select count(*), sum(total), avg(total), min(at), max(at)
get orders select status, count(*), avg(total) where year = 2026 group status
get orders select customer, sum(total) group customer order sum(total) desc limit 10
get events select bucket(at, 1h) as hour, name, count(distinct user) as users group hour, name
get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low,
last(px by at) as close, sum(qty) as volume where sym = $1 group bar
get ticks select sym, sum(px * qty) / sum(qty) as vwap group sym
get events select props.plan, sum(props.v) group props.planawait db.from('orders')
.select('count(*)', 'sum(total)', 'avg(total)', 'min(at)', 'max(at)')
.rows();
await db.from('orders')
.select('status', 'count(*)', 'avg(total)')
.where('year', 2026)
.group('status')
.rows();
await db.from('orders')
.select('customer', 'sum(total)')
.group('customer')
.order('sum(total)', 'desc')
.limit(10)
.rows();
await db.from('events')
.select(
bucket('at', '1h').as('hour'),
'name',
countDistinct('user').as('users')
)
.group('hour', 'name')
.rows();
await db.from('ticks')
.select(
bucket('at', '1m').as('bar'),
first('px', 'at').as('open'),
expr('max(px)').as('high'),
expr('min(px)').as('low'),
last('px', 'at').as('close'),
expr('sum(qty)').as('volume')
)
.where('sym', 'S07')
.group('bar')
.rows();
await db.from('ticks')
.select('sym', expr('sum(px * qty) / sum(qty)').as('vwap'))
.group('sym')
.rows();
await db.from('events')
.select('props.plan', 'sum(props.v)')
.group('props.plan')
.rows();(db.collection("orders")
.select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)")
.rows())
(db.collection("orders")
.select("status", "count(*)", "avg(total)")
.where("year", 2026)
.group("status")
.rows())
(db.collection("orders")
.select("customer", "sum(total)")
.group("customer")
.order("sum(total)", "desc")
.limit(10)
.rows())
(db.collection("events")
.select(
bucket("at", "1h").as_("hour"),
"name",
count_distinct("user").as_("users")
)
.group("hour", "name")
.rows())
(db.collection("ticks")
.select(
bucket("at", "1m").as_("bar"),
first("px", "at").as_("open"),
expr("max(px)").as_("high"),
expr("min(px)").as_("low"),
last("px", "at").as_("close"),
expr("sum(qty)").as_("volume")
)
.where("sym", "S07")
.group("bar")
.rows())
(db.collection("ticks")
.select("sym", expr("sum(px * qty) / sum(qty)").as_("vwap"))
.group("sym")
.rows())
(db.collection("events")
.select("props.plan", "sum(props.v)")
.group("props.plan")
.rows())db.From("orders").
Select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)").
Rows(ctx)
db.From("orders").
Select("status", "count(*)", "avg(total)").
Where("year", "=", 2026).
Group("status").
Rows(ctx)
db.From("orders").
Select("customer", "sum(total)").
Group("customer").
Order("sum(total)", "desc").
Limit(10).
Rows(ctx)
db.From("events").
Select(
fenecdb.Bucket("at", "1h").As("hour"),
"name",
fenecdb.CountDistinct("user").As("users"),
).
Group("hour", "name").
Rows(ctx)
db.From("ticks").
Select(
fenecdb.Bucket("at", "1m").As("bar"),
fenecdb.First("px", "at").As("open"),
fenecdb.Expr("max(px)").As("high"),
fenecdb.Expr("min(px)").As("low"),
fenecdb.Last("px", "at").As("close"),
fenecdb.Expr("sum(qty)").As("volume"),
).
Where("sym", "=", "S07").
Group("bar").
Rows(ctx)
db.From("ticks").
Select("sym", fenecdb.Expr("sum(px * qty) / sum(qty)").As("vwap")).
Group("sym").
Rows(ctx)
db.From("events").
Select("props.plan", "sum(props.v)").
Group("props.plan").
Rows(ctx)await db.From("orders")
.Select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)")
.RowsAsync();
await db.From("orders")
.Select("status", "count(*)", "avg(total)")
.Where("year", 2026)
.Group("status")
.RowsAsync();
await db.From("orders")
.Select("customer", "sum(total)")
.Group("customer")
.Order("sum(total)", "desc")
.Limit(10)
.RowsAsync();
await db.From("events")
.Select(
Computed.Bucket("at", "1h").As("hour"),
"name",
Computed.CountDistinct("user").As("users")
)
.Group("hour", "name")
.RowsAsync();
await db.From("ticks")
.Select(
Computed.Bucket("at", "1m").As("bar"),
Computed.First("px", "at").As("open"),
Computed.Expr("max(px)").As("high"),
Computed.Expr("min(px)").As("low"),
Computed.Last("px", "at").As("close"),
Computed.Expr("sum(qty)").As("volume")
)
.Where("sym", "S07")
.Group("bar")
.RowsAsync();
await db.From("ticks")
.Select("sym", Computed.Expr("sum(px * qty) / sum(qty)").As("vwap"))
.Group("sym")
.RowsAsync();
await db.From("events")
.Select("props.plan", "sum(props.v)")
.Group("props.plan")
.RowsAsync();try await db.from("orders")
.select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)")
.rows()
try await db.from("orders")
.select("status", "count(*)", "avg(total)")
.where("year", 2026)
.group("status")
.rows()
try await db.from("orders")
.select("customer", "sum(total)")
.group("customer")
.order("sum(total)", "desc")
.limit(10)
.rows()
try await db.from("events")
.select(
.bucket("at", "1h").as("hour"),
"name",
.countDistinct("user").as("users")
)
.group("hour", "name")
.rows()
try await db.from("ticks")
.select(
.bucket("at", "1m").as("bar"),
.first("px", by: "at").as("open"),
.expr("max(px)").as("high"),
.expr("min(px)").as("low"),
.last("px", by: "at").as("close"),
.expr("sum(qty)").as("volume")
)
.where("sym", "S07")
.group("bar")
.rows()
try await db.from("ticks")
.select("sym", .expr("sum(px * qty) / sum(qty)").as("vwap"))
.group("sym")
.rows()
try await db.from("events")
.select("props.plan", "sum(props.v)")
.group("props.plan")
.rows()db.from("orders")
.select("count(*)", "sum(total)", "avg(total)", "min(at)", "max(at)")
.rows()
db.from("orders")
.select("status", "count(*)", "avg(total)")
.where("year", 2026)
.group("status")
.rows()
db.from("orders")
.select("customer", "sum(total)")
.group("customer")
.order("sum(total)", "desc")
.limit(10)
.rows()
db.from("events")
.select(
Computed.bucket("at", "1h").alias("hour"),
"name",
Computed.countDistinct("user").alias("users")
)
.group("hour", "name")
.rows()
db.from("ticks")
.select(
Computed.bucket("at", "1m").alias("bar"),
Computed.first("px", "at").alias("open"),
Computed.expr("max(px)").alias("high"),
Computed.expr("min(px)").alias("low"),
Computed.last("px", "at").alias("close"),
Computed.expr("sum(qty)").alias("volume")
)
.where("sym", "S07")
.group("bar")
.rows()
db.from("ticks")
.select("sym", Computed.expr("sum(px * qty) / sum(qty)").alias("vwap"))
.group("sym")
.rows()
db.from("events")
.select("props.plan", "sum(props.v)")
.group("props.plan")
.rows()await db.from('orders')
.select(['count(*)', 'sum(total)', 'avg(total)', 'min(at)', 'max(at)'])
.rows();
await db.from('orders')
.select(['status', 'count(*)', 'avg(total)'])
.where('year', 2026)
.group('status')
.rows();
await db.from('orders')
.select(['customer', 'sum(total)'])
.group('customer')
.order('sum(total)', 'desc')
.limit(10)
.rows();
await db.from('events')
.select([
Computed.bucket('at', '1h').as('hour'),
'name',
Computed.countDistinct('user').as('users')
])
.group(['hour', 'name'])
.rows();
await db.from('ticks')
.select([
Computed.bucket('at', '1m').as('bar'),
Computed.first('px', 'at').as('open'),
Computed.expr('max(px)').as('high'),
Computed.expr('min(px)').as('low'),
Computed.last('px', 'at').as('close'),
Computed.expr('sum(qty)').as('volume')
])
.where('sym', 'S07')
.group('bar')
.rows();
await db.from('ticks')
.select(['sym', Computed.expr('sum(px * qty) / sum(qty)').as('vwap')])
.group('sym')
.rows();
await db.from('events')
.select(['props.plan', 'sum(props.v)'])
.group('props.plan')
.rows();get orders select count(*), sum(total), avg(total), min(at), max(at)
get orders select status, count(*), avg(total) where year = $1 group status
-- [2026]
get orders select customer, sum(total) group customer order sum(total) desc limit $1
-- [10]
get events select bucket(at, 1h) as hour, name, count(distinct user) as users group hour, name
get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 group bar
-- ["S07"]
get ticks select sym, sum(px * qty) / sum(qty) as vwap group sym
get events select props.plan, sum(props.v) group props.plan- Nulls are skipped, as in SQL, by everything but
count(*). - An aggregate folds any expression —
sum(px * qty)— and an item can be an expression over aggregates and keys —sum(px * qty) / sum(qty). - Groups can be ordered and paged by any column of the list.
Without
group,order,limitandoffsetpick the rows the one row folds:get deliveries select count(*), avg(km) where courier = $1 order at desc limit 50is the latest 50. - A
lookuphangs off the groups, keyed by a column of the list, and a lookup level's list may aggregate, each parent's children folded. - Aggregates do not combine with
nearormatch.
Each aggregate, group keys and having
A select list with an aggregate in it folds the rows the query finds —
through the same indexes any where reaches — into one row, or,
with group, one row per distinct set of the keys' values. Each
column answers under its name, in the order the list is written: the name
as gives it, or for a field or an aggregate of one the text it
was written as — sum(total), count(distinct user),
first(px) (its by left out). Anything else needs
as.
- Keys.
grouptakes one key or several, each a field, a path, or an expression —group sym, bucket(at, 1m). A name the list gives an item withasstands for that item, before a field of the same name:select bucket(at, 1h) as hour, count(*) group hour. A plain item of the list is a key itself, or is refused as neither aggregated nor grouped by; an expression written out again ingroupis not matched to the list's — name it once, withas. count(*)counts rows.count(distinct e)counts the valuesetakes, a null none, two values the same when their encodings are — asgrouptells its keys apart. It is exact: every value is held once a group, up to a million over every group and item of the query — about 70 MB — and a query past that is refused, never counted short.sumandavgtake numbers: anintsum is anint, and one past 64 bits is an error rather than a wrapped number; a float among the values makes the sum afloat, and an average is one.minandmaxtake any value with an order — numbers,timestamp,text,bool— a text field's in its collation. A field whose type cannot be folded is refused before any row is read, an expression's value at the first row that cannot.approx_count_distinct(e)counts distinct values in a HyperLogLog sketch of 214 registers, 16 KB a group at most, with a standard error of 0.81% at any count and no million to stop at.hll_accumulate(e)answers the sketch as bytes,hll_combine(s)merges sketches and the functionhll_estimate(s)reads a count off one, so days kept as sketches count a month. A query's sketches are held to 64 MB.having <expr>aftergroupkeeps the groups it holds for, beforeorderandlimit: it reads the keys, the list's columns by name and any aggregate,group user having count(*) >= 2.countafter it answers how many groups passed.first(e by k)andlast(e by k)are the value ofeon the group's row least, or greatest, byk— a bar's open and close arefirst(px by at)andlast(px by at), whatever order the ticks were written in. A tie onkgoes to the row written first forfirstand last forlast; withoutby, the order is the one the rows were written in. A row whose value or key is null is passed over, as every aggregate passes a null over. The query'sorderorders the groups, not the rows inside them, so it says nothing to these.- Nulls are skipped, as SQL skips them, by everything but
count(*). Over no rows — or only nulls — a sum, an average, a minimum and a maximum arenull, not 0. - Groups come out by their keys, in the order
groupnames them, the null key first, unlessordernames a column of the list —order sum(total) desc,order vwap— andlimitandoffsetpage them. - Ungrouped, a list answers one row, which an order or a page
would have nothing to do with; so there
order,limitandoffsetpick the rows it folds, as they pick aget's page — an@sortedfield's index walked to it — and the one row folds those:select avg(km) order at desc limit 50is the mean of the latest 50, what SQL writes as a subquery. Its keys are the rows' values; a column of the list among them is refused. - A
lookupafter the list hangs off its rows — the groups, or the one row — and names a column of the list as the parent's key:get orders select courier, count(*) as n group courier order n desc limit 10 lookup couriers on user = courier select nameis the ten busiest couriers with their names. The column holds a field's values: a key, itsbucket, itsmin,max,firstorlast; a sum's or a count's is refused, having no type to hold the child's key to.requiredthere drops the groups no child matches, beforeorderandlimit, andcountaftergroupcounts those kept. - What does not combine is what ranks rows an aggregate
collapses:
nearandmatch.
How it runs. Every item is bound to the collection once a query:
each field and path the list reads is decoded in one pass a row, a field
folded as it is is read with nothing copied, and a bucket over
a constant interval works the interval out once. Rows written in time order
come a bucket at a time, so the group the row before went to is asked
first. A count(*) by bucket of a
timestamp or int field with an ordered index
(@sorted, @ttl), whose filter is a range of that
field and nothing more, reads the index's keys and no row; explain
says so.
A million rows in 100 groups, select cat, count(*), avg(price) group
cat, took 80 ms in process where PostgreSQL 17 — one worker, as
make pgvector-up runs it — took 189 ms of execution time on the
same rows; the whole set's count, sum, minimum and
maximum, 37 ms; the same groups over the tenth a @sorted range
found, 9.3 ms. The rows are read, not an index: a filter narrows them, nothing
precomputes the totals. The questions analytics and market data ask —
bars, VWAP, distinct users, counts by the minute — are on Analytics and market data, each with what it
costs over a million rows.
Totals per group
get orders select status, count(*), sum(total) where year = 2026 group status
order sum(total) desc limit 3await db.from('orders')
.select('status', 'count(*)', 'sum(total)')
.where('year', 2026)
.group('status')
.order('sum(total)', 'desc')
.limit(3)
.rows();(db.collection("orders")
.select("status", "count(*)", "sum(total)")
.where("year", 2026)
.group("status")
.order("sum(total)", "desc")
.limit(3)
.rows())db.From("orders").
Select("status", "count(*)", "sum(total)").
Where("year", "=", 2026).
Group("status").
Order("sum(total)", "desc").
Limit(3).
Rows(ctx)await db.From("orders")
.Select("status", "count(*)", "sum(total)")
.Where("year", 2026)
.Group("status")
.Order("sum(total)", "desc")
.Limit(3)
.RowsAsync();try await db.from("orders")
.select("status", "count(*)", "sum(total)")
.where("year", 2026)
.group("status")
.order("sum(total)", "desc")
.limit(3)
.rows()db.from("orders")
.select("status", "count(*)", "sum(total)")
.where("year", 2026)
.group("status")
.order("sum(total)", "desc")
.limit(3)
.rows()await db.from('orders')
.select(['status', 'count(*)', 'sum(total)'])
.where('year', 2026)
.group('status')
.order('sum(total)', 'desc')
.limit(3)
.rows();get orders select status, count(*), sum(total) where year = $1 group status order sum(total) desc limit $2
-- [2026, 3]Bars by the minute
get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high,
min(px) as low, last(px by at) as close, sum(qty) as volume
where sym = $1 and at >= $2 group barawait db.from('ticks')
.select(
bucket('at', '1m').as('bar'),
first('px', 'at').as('open'),
expr('max(px)').as('high'),
expr('min(px)').as('low'),
last('px', 'at').as('close'),
expr('sum(qty)').as('volume')
)
.where('sym', 'S07')
.where('at', '>=', '2026-05-03T09:00:00Z')
.group('bar')
.rows();(db.collection("ticks")
.select(
bucket("at", "1m").as_("bar"),
first("px", "at").as_("open"),
expr("max(px)").as_("high"),
expr("min(px)").as_("low"),
last("px", "at").as_("close"),
expr("sum(qty)").as_("volume")
)
.where("sym", "S07")
.where("at", ">=", "2026-05-03T09:00:00Z")
.group("bar")
.rows())db.From("ticks").
Select(
fenecdb.Bucket("at", "1m").As("bar"),
fenecdb.First("px", "at").As("open"),
fenecdb.Expr("max(px)").As("high"),
fenecdb.Expr("min(px)").As("low"),
fenecdb.Last("px", "at").As("close"),
fenecdb.Expr("sum(qty)").As("volume"),
).
Where("sym", "=", "S07").
Where("at", ">=", "2026-05-03T09:00:00Z").
Group("bar").
Rows(ctx)await db.From("ticks")
.Select(
Computed.Bucket("at", "1m").As("bar"),
Computed.First("px", "at").As("open"),
Computed.Expr("max(px)").As("high"),
Computed.Expr("min(px)").As("low"),
Computed.Last("px", "at").As("close"),
Computed.Expr("sum(qty)").As("volume")
)
.Where("sym", "S07")
.Where("at", ">=", "2026-05-03T09:00:00Z")
.Group("bar")
.RowsAsync();try await db.from("ticks")
.select(
.bucket("at", "1m").as("bar"),
.first("px", by: "at").as("open"),
.expr("max(px)").as("high"),
.expr("min(px)").as("low"),
.last("px", by: "at").as("close"),
.expr("sum(qty)").as("volume")
)
.where("sym", "S07")
.where("at", ">=", "2026-05-03T09:00:00Z")
.group("bar")
.rows()db.from("ticks")
.select(
Computed.bucket("at", "1m").alias("bar"),
Computed.first("px", "at").alias("open"),
Computed.expr("max(px)").alias("high"),
Computed.expr("min(px)").alias("low"),
Computed.last("px", "at").alias("close"),
Computed.expr("sum(qty)").alias("volume")
)
.where("sym", "S07")
.where("at", ">=", "2026-05-03T09:00:00Z")
.group("bar")
.rows()await db.from('ticks')
.select([
Computed.bucket('at', '1m').as('bar'),
Computed.first('px', 'at').as('open'),
Computed.expr('max(px)').as('high'),
Computed.expr('min(px)').as('low'),
Computed.last('px', 'at').as('close'),
Computed.expr('sum(qty)').as('volume')
])
.where('sym', 'S07')
.where('at', '>=', '2026-05-03T09:00:00Z')
.group('bar')
.rows();get ticks select bucket(at, 1m) as bar, first(px by at) as open, max(px) as high, min(px) as low, last(px by at) as close, sum(qty) as volume where sym = $1 and at >= $2 group bar
-- ["S07", "2026-05-03T09:00:00Z"]