PartiQL: aggregates & profiling
Part of the dialect series: answer "how many?", "what's the top ten?", and "what shape is this data, even?" without exporting to a spreadsheet. DynamoDB has no aggregates — the studio folds reads into answers client-side, with every item read disclosed and billed honestly.
- Answer "how many?", "top ten?", and "what shape is this data?" in the query, not a spreadsheet
- Fold with
COUNT/SUM/AVG/MIN/MAX(andDISTINCT) — and know every folded row is billed - Group with
GROUP BY, filter groups withHAVING, and re-aimORDER BY/LIMITat the result rows - Discover a schemaless table's shape with
PROFILE TABLE
Aggregates in depth
SELECT COUNT(*) AS total, AVG(Points) AS avgFROM "stage.Points"WHERE pk = 'User#9'LIMIT 1000 DynamoDB has no aggregates: the matched read pages through (capped) and folds client-side into one result row. Run status disclosures report items and pages scanned — every matched item is read and billed, so bound the input with WHERE and LIMIT. Optional AS names each output column.
DISTINCTfolds over distinct values:COUNT(DISTINCT attr)counts distinct non-null values (deep equality, per group when grouped), andSUM/AVG(DISTINCT attr)fold over them.DISTINCTis refused onMIN/MAX— it's a no-op there.- The target may be a document path, not just a top-level attribute —
SUM(order.total),MAX(stats.p99). The native read projects the path's root and the fold navigates in; a path that resolves to MISSING is skipped like any absent value. - On an ungrouped aggregate, a statement
LIMITbounds the rows folded (the input), since exactly one row comes out. - Aggregates ride any read shape the dialect supports: key-condition Queries, GSI targets, filtered Scans (the Scan warning from Targeted reads still applies).
- Not combinable with
IN (SELECT …)in the same statement, and?parameters don't mix with aggregates — both refuse with a message.
GROUP BY
SELECT Status, COUNT(*) AS n, SUM(Points) AS totalFROM "stage.Points"GROUP BY StatusORDER BY n DESCLIMIT 10 One result row per distinct key combination — grouping happens client-side during the same capped drain, keyed by deep value equality; an absent key and an explicit NULL group together. Group keys are top-level attributes — one or more, optionally AS-renamed — and GROUP BY without any aggregate is also valid (one row per distinct value, a server-billed SELECT DISTINCT over the whole match).
With GROUP BY, the trailing clauses re-aim at the result rows: ORDER BY sorts the folded groups (by key or aggregate column name) and LIMIT bounds the groups returned — ORDER BY n DESC LIMIT 10 means "top ten groups by count", exactly as written.
- Every plain SELECT column must be a grouped attribute, and every group key must appear in the SELECT list — violations refuse with the column named.
- Group keys may be document paths (
GROUP BY address.city); an unaliased path names its column by the last step, and the matching SELECT column must be the same path. - Up to 10,000 distinct groups; past the cap the run refuses and recommends a narrower WHERE.
HAVING
SELECT Status, COUNT(*) AS nFROM "stage.Points"GROUP BY StatusHAVING COUNT(*) >= 2 AND Status <> 'noise'ORDER BY n DESC HAVING filters the folded result rows — after grouping, before the re-aimed ORDER BY / LIMIT. It speaks the full expression grammar; reference an aggregate by repeating its spelling (COUNT(*)) or its AS alias, and a group key by name. Place it right after GROUP BY. Two honest rules: an aggregate used in HAVING must also appear in the SELECT list (the fold computes only what the list names), and a reference that isn't a result column refuses with the available columns named — a typo never silently empties the result. HAVING on an ungrouped fold filters the single result row.
PROFILE TABLE
PROFILE TABLE "stage.Orders"PROFILE TABLE "stage.Orders" WHERE tenantId = 'kanject' LIMIT 5000 Schema discovery over schemaless data: one result row per attribute — coverage against the sample, type distribution ("Number 94% · String 6%"), NULL vs MISSING vs empty counts, and a "mixed types" note when an attribute carries more than one shape. WHERE bounds what's sampled, LIMIT bounds the rows read, and a profile that hits the read cap returns a disclosed partial sample ("sampled the first N items — the table continues") rather than refusing — unlike an aggregate, a sample is an honest answer.
Caps for these features
- Aggregates / GROUP BY: 100 pages · 10,000 distinct groups.
- PROFILE TABLE: 100 pages · up to 1,000 distinct attribute names; a profile that hits the page cap returns a disclosed partial sample, not a refusal.
- Every cap breach refuses with a named fix — typically a narrower WHERE or a smaller LIMIT.
- On Professional, the aggregate and GROUP BY caps are tunable per install (Settings → Querying); raising one can increase a query's read cost.
- Using Global Secondary Indexes for materialized aggregation queries AWS docs: the native pattern for aggregates a hot path reads repeatedly — precompute and store, rather than re-folding a read each time.
- Scanning tables in DynamoDB AWS docs: an unbounded fold drains a Scan — this is what it bills.
- DynamoDB has no aggregates — the studio folds a read client-side, billing every matched item, so bound it with
WHEREandLIMIT. GROUP BYmakes one row per distinct key; the trailingORDER BY/LIMITthen re-aim at the folded groups ("top ten by count").HAVINGfilters the folded rows — an aggregate it references must also appear in the SELECT list.PROFILE TABLEsamples a schemaless table into one row per attribute; a capped profile is an honest partial sample, not a refusal.
SELECT COUNT(*) FROM "stage.Points" folds over a full-table Scan. Count only user 9's rows.Show solution
SELECT COUNT(*) AS total FROM "stage.Points"WHERE pk = 'User#9' stage.Points, largest first.GROUP BY, the trailing ORDER BY / LIMIT re-aim at the folded groups, not the read.Show solution
ORDER BY n DESC LIMIT 5 means "top five groups by count", exactly as written — both trailing clauses re-aim at the result rows.SELECT Status, COUNT(*) AS nFROM "stage.Points"GROUP BY StatusORDER BY n DESCLIMIT 5 HAVING filters the folded result rows — and an aggregate it references must also appear in the SELECT list.Show solution
HAVING COUNT(*) >= 100 filters after grouping, before the re-aimed ORDER BY. COUNT(*) stays in the SELECT list because the fold computes only what the list names.SELECT Status, COUNT(*) AS nFROM "stage.Points"GROUP BY StatusHAVING COUNT(*) >= 100ORDER BY n DESC