Aggregate Functions
Ten functions that turn many rows into one: count, count_distinct, sum, avg, min, max,
string_agg, median, quantile_cont and quantile_disc. 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 shippedcount() 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.
Counting different rows takes the whole key
count_distinct also takes several values, and then counts how many different combinations
there are. That exists because a row is not always identified by one column — naming the entity
counts order lines:
FIND order_details od FETCH count_distinct(od) n| what you write | what it counts | |
|---|---|---|
count_distinct(od) |
order lines | 2155 |
count_distinct(od.order_id) |
orders | 830 |
An order line is identified by order and product together. Both are legitimate questions; they are just different ones, and writing the entity asks the first.
Engines disagree about how to spell it, and koryki hides that:
| rendered as | |
|---|---|
| DuckDB, PostgreSQL, Trino | COUNT(DISTINCT (a, b)) |
| MariaDB, Snowflake | COUNT(DISTINCT a, b) |
| Oracle, SQL Server, SQLite | refused — the engine has neither form |
Where it is refused, the message names the way out: concatenate the values yourself.
FIND order_details od FETCH count_distinct(concat(od.order_id, '#', od.product_id)) nChoose that separator carefully, and do not omit it. Without one, (1, 23) and (12, 3) both
become 123 and count as the same combination; with one that occurs in the data, the same collision
comes back for the rows that contain it. koryki does not pick the separator for you precisely
because no character can be guaranteed absent from your values — that decision has to be yours.
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.
median and the quantile functions are not everywhere
median, quantile_cont and quantile_disc read the sorted values rather than reduce them
arithmetically, which not every engine can express as a plain aggregate. DuckDB, PostgreSQL, Oracle
and Snowflake support all three natively; MariaDB, SQLite, SQL Server and Trino refuse them
outright, with a message naming the function, before the query ever runs.
quantile_cont(fraction, x) interpolates between the two nearest sorted values when fraction
falls between them, so the result need not be one of the inputs — quantile_cont(0.5, x) is
median(x). quantile_disc(fraction, x) never interpolates: the result is always one of the
actual input values, which is also why it, alone among the three, keeps its argument's own type
rather than always coming back fractional.
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_countGenerated SQL
all dialects
-- count: number of orders.
SELECT
count(o.order_id) AS order_count
FROM
orders ocount_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.
count_distinct(value: any, more: any, ...) → BIGINT (aggregate)
Number of distinct combinations of the given values — how many different order lines, say, when a line is identified by order and product together.
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) ordersGenerated 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.countryavg
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_priceGenerated 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 odThe 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_quantityGenerated SQL
all dialects
-- sum: total quantity ordered across all lines.
SELECT
sum(od.quantity) AS total_quantity
FROM
order_details odmin
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) earliestGenerated SQL
all dialects
-- min: earliest order date.
SELECT
min(o.order_date) AS earliest
FROM
orders omax
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) latestGenerated SQL
all dialects
-- max: latest order date.
SELECT
max(o.order_date) AS latest
FROM
orders ostring_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) namesGenerated 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_idThe 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 |
median
median(value: numeric) → FLOAT (aggregate)
Middle value of the inputs once sorted -- the average of the two middle values when there is an even number of them, so the result need not be one of the inputs. Prefer it to avg for a value skewed by outliers: a few near-zero denominators drag the average away but move the median only if they are actually in the middle.
| Argument | Type | Description |
|---|---|---|
| value | numeric | the numbers to take the middle of |
Sample query:
// median: the middle unit price of each category's products, once sorted.
FIND products p, p categories c
FETCH c.category_name, median(p.unit_price) median_priceGenerated SQL
duckdb · oracle · snowflake
-- median: the middle unit price of each category's products, once sorted.
SELECT
c.category_name
, MEDIAN(p.unit_price) AS median_price
FROM
products p
INNER JOIN categories c ON
p.category_id = c.category_id
GROUP BY
c.category_nameThe remaining dialects differ only in this expression:
| Dialect | Expression |
|---|---|
| postgresql | , PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY p.unit_price) AS median_price |
Unsupported: mariadb, mssql, sqlite, trino
quantile_cont
quantile_cont(fraction: numeric, value: numeric) → FLOAT (aggregate)
The value at fraction of the way through the sorted inputs, interpolating between the two nearest when it falls between them -- so the result need not be one of the inputs. quantile_cont(0.5, x) is median(x); quantile_cont(0.25, x) the lower quartile.
| Argument | Type | Description |
|---|---|---|
| fraction | numeric | where in the sorted values to read, from 0 (the first) to 1 (the last); 0.5 is the median |
| value | numeric | the numbers to take the quantile of |
Sample query:
// quantile_cont: the price a quarter of the way up each category's sorted unit prices, interpolated.
FIND products p, p categories c
FETCH c.category_name, quantile_cont(0.25, p.unit_price) lower_quartileGenerated SQL
duckdb · oracle · snowflake · postgresql
-- quantile_cont: the price a quarter of the way up each category's sorted unit prices, interpolated.
SELECT
c.category_name
, PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY p.unit_price) AS lower_quartile
FROM
products p
INNER JOIN categories c ON
p.category_id = c.category_id
GROUP BY
c.category_nameUnsupported: mariadb, mssql, sqlite, trino
Results differ from the other dialects on duckdb.
quantile_disc
quantile_disc(fraction: numeric, value: ORDERED) → argument-dependent (aggregate)
Like quantile_cont, but never interpolates: the result is always one of the input values, the one that sits at or just past fraction of the way through. Works on any value that can be ordered, not only numbers -- a quantile of dates, say -- which quantile_cont cannot do.
| Argument | Type | Description |
|---|---|---|
| fraction | numeric | where in the sorted values to read, from 0 (the first) to 1 (the last) |
| value | ORDERED | the values to take the quantile of |
Sample query:
// quantile_disc: like quantile_cont, but always one of the actual prices, never interpolated.
FIND products p, p categories c
FETCH c.category_name, quantile_disc(0.25, p.unit_price) lower_quartileGenerated SQL
duckdb · oracle · snowflake · postgresql
-- quantile_disc: like quantile_cont, but always one of the actual prices, never interpolated.
SELECT
c.category_name
, PERCENTILE_DISC(0.25) WITHIN GROUP (ORDER BY p.unit_price) AS lower_quartile
FROM
products p
INNER JOIN categories c ON
p.category_id = c.category_id
GROUP BY
c.category_nameUnsupported: mariadb, mssql, sqlite, trino
list_agg
list_agg(value: any) → argument-dependent (aggregate)
Collects the values of the group into a list, NULL values left out. The order is unspecified: sort the list to compare it.
Operands: any type.
Generated SQL
Unsupported: mariadb, mssql, sqlite