Aggregate Functions
Seven functions that turn many rows into one: count, count_distinct, sum, avg, min, max
and string_agg. You never write GROUP BY — using one of these in a projection is what makes the
query grouped, and the columns you also fetch become the grouping key.
count() and count(x) ask different questions
count() counts rows. count(x) counts the rows where x is not blank, which is how you ask
"how many orders actually have a shipping date". The two differ exactly by the blanks, and that is
usually the point of writing the second one.
count() → 830
count(o.shipped_date) → 809 -- 21 orders never shipped
count() becomes COUNT(*), not count(). Only DuckDB, SQLite and Trino accept the empty
parentheses; PostgreSQL, SQL Server, MariaDB, Oracle and Snowflake reject them outright with "must
be used to call a parameterless aggregate function". That the reference dialect is one of the three
permissive ones is why it went unnoticed for a while.
count_distinct(x) is its own name rather than a modifier on count, because DISTINCT inside a
function call is SQL syntax that not every engine allows in every position — see the window note
below.
sum keeps the type, avg does not
sum, min and max return whatever the argument was: a sum of integers is an integer, a sum of
money is money. avg always comes back fractional, because an average of whole numbers usually
isn't one.
That distinction is worth stating because an engine can disagree: Trino used to return the average
of a DECIMAL(10,2) column as DECIMAL(10,2) as well, so the mean of three prices came out
11.92 where every other engine said 11.916667. The average of money is not money, and it is
levelled now.
string_agg needs its separator
string_agg(o.ship_city, ', ')
There is no default — the separator is a required argument, because the engines disagree on what a
missing one means. Each spells the function differently (GROUP_CONCAT, LISTAGG,
array_join(array_agg(…))), which is invisible until you try to use it as a window function.
Not every aggregate can be a window function
string_agg has no OVER form on MariaDB, SQL Server, Oracle, Snowflake or Trino, and
count_distinct has none on MariaDB, SQL Server, PostgreSQL or SQLite. DuckDB is the only engine
that allows both. Where the engine cannot, the query is refused before it runs, with a message
naming the function and pointing at it — not a driver error after the fact.
count
count([value: any]) → BIGINT (aggregate)
Number of input rows, or of non-null values when an expression is given.
Operands: any type.
Sample query:
// count: number of orders.
FIND orders o
FETCH count(o.order_id) order_count
Generated SQL
all dialects
-- count: number of orders.
SELECT
count(o.order_id) AS order_count
FROM
orders o
count_distinct
count_distinct(value: any) → BIGINT (aggregate)
Number of distinct non-null values — how many different customers, say, rather than how many rows.
Operands: any type.
Sample query:
// count_distinct: how many different customers ordered from each country — not how many orders,
// which is what plain count would give. The one function every BI tool exposes under its own name.
FIND customers c, orders o
FETCH c.country, count_distinct(c.customer_id) customers, count(o.order_id) orders
Generated SQL
all dialects
-- count_distinct: how many different customers ordered from each country — not how many orders,
-- which is what plain count would give. The one function every BI tool exposes under its own name.
SELECT
c.country
, COUNT(DISTINCT c.customer_id) AS customers
, count(o.order_id) AS orders
FROM
customers c
INNER JOIN orders o ON
c.customer_id = o.customer_id
GROUP BY
c.country
avg
avg(value: numeric | duration) → FLOAT (aggregate)
Average of the input values.
| Argument | Type | Description |
|---|---|---|
| value | numeric | duration | the numeric values to average |
Sample query:
// avg: average line-item unit price.
FIND order_details od
FETCH avg(od.unit_price) avg_price
Generated SQL
duckdb · oracle · snowflake · mssql · postgresql · mariadb · sqlite
-- avg: average line-item unit price.
SELECT
avg(od.unit_price) AS avg_price
FROM
order_details od
The remaining dialects differ only in this expression:
| Dialect | Expression |
|---|---|
| trino | avg(CAST(od.unit_price AS DOUBLE)) AS avg_price |
sum
sum(value: numeric | duration) → argument-dependent (aggregate)
Sum of the input values.
| Argument | Type | Description |
|---|---|---|
| value | numeric | duration | the values to add together |
Sample query:
// sum: total quantity ordered across all lines.
FIND order_details od
FETCH sum(od.quantity) total_quantity
Generated SQL
all dialects
-- sum: total quantity ordered across all lines.
SELECT
sum(od.quantity) AS total_quantity
FROM
order_details od
min
min(value: any) → argument-dependent (aggregate)
Minimum input value.
Operands: any type.
Sample query:
// min: earliest order date.
FIND orders o
FETCH min(o.order_date) earliest
Generated SQL
all dialects
-- min: earliest order date.
SELECT
min(o.order_date) AS earliest
FROM
orders o
max
max(value: any) → argument-dependent (aggregate)
Maximum input value.
Operands: any type.
Sample query:
// max: latest order date.
FIND orders o
FETCH max(o.order_date) latest
Generated SQL
all dialects
-- max: latest order date.
SELECT
max(o.order_date) AS latest
FROM
orders o
string_agg
string_agg(value: any, separator: TEXT) → TEXT (aggregate)
Concatenates non-null input values into a string, separated by separator. The order is unspecified: no engine promises one for an aggregate without an explicit sort, so the same query may answer differently on another dialect, another plan, or another run. Pass order_by to fix it.
| Argument | Type | Description |
|---|---|---|
| value | any | the values to concatenate |
| separator | TEXT | text placed between consecutive values |
string_agg(value: any, separator: TEXT, order_by: any) → TEXT (aggregate)
Concatenates non-null input values into a string, separated by separator, in ascending order of order_by. The two-argument form leaves the order to the engine.
| Argument | Type | Description |
|---|---|---|
| value | any | the values to concatenate |
| separator | TEXT | text placed between consecutive values |
| order_by | any | the expression the values are sorted by |
Sample query:
// string_agg: the product names of a category, in one cell, in a fixed order.
//
// Sorted by product_id rather than by name on purpose: a text sort depends on the engine's
// collation, so the same names come back in a different order on PostgreSQL and Oracle than on
// DuckDB. An integer sort is the same everywhere.
FIND products p
FETCH p.category_id category, string_agg(p.product_name, ', ', p.product_id) names
Generated SQL
duckdb · postgresql · sqlite
-- string_agg: the product names of a category, in one cell, in a fixed order.
-- Sorted by product_id rather than by name on purpose: a text sort depends on the engine's
-- collation, so the same names come back in a different order on PostgreSQL and Oracle than on
-- DuckDB. An integer sort is the same everywhere.
SELECT
p.category_id AS category
, string_agg(p.product_name, ', ' ORDER BY p.product_id) AS names
FROM
products p
GROUP BY
p.category_id
The remaining dialects differ only in this expression:
| Dialect | Expression |
|---|---|
| oracle · snowflake | , LISTAGG(p.product_name, ', ') WITHIN GROUP (ORDER BY p.product_id) AS names |
| mariadb | , GROUP_CONCAT(p.product_name ORDER BY p.product_id SEPARATOR ', ') AS names |
| mssql | , STRING_AGG(p.product_name, ', ') WITHIN GROUP (ORDER BY p.product_id) AS names |
| trino | , array_join(array_agg(p.product_name ORDER BY p.product_id), ', ') AS names |