# Statistics functions
> Describe a set of numbers: spread (stddev, variance, mad, iqr, range), shape (skewness, kurtosis), other averages (geomean, harmonic_mean, mode) and exact percentiles.
- Category: Functions — statistics
- Keywords: statistics, spread, dispersion, std, standard deviation, volatility, risk, variance, sample, population, stdev, std, var, stddev\_population, variance\_samp, variance\_pop, shape, skew, asymmetry, kurt, fat tails, tail risk, crash risk, excess kurtosis, geometric mean, average growth, harmonic mean, average cost, most common value, most frequent, quantile, median\_cont, percentile\_continuous, percentile\_discrete, WITHIN GROUP, ordered-set aggregate, MAD, robust spread, outlier, interquartile range, quartile, max minus min, high low range, sum of squares, realized volatility, Shannon entropy, diversity, approx\_distinct, ndv
- See also: [Aggregate functions](functions-aggregate.md), [Correlation and regression](functions-correlation.md), [Returns and risk](functions-returns-risk.md), [Window and series functions (aggregates)](functions-window.md#aggregates), [Technical indicators](functions-indicators.md), [Math functions (sqrt)](functions-math.md#sqrt)
Statistics functions describe a set of numbers with one value. Most of them are **aggregates**: they read many rows and give one value per group, like `avg` ([aggregate functions](functions-aggregate.md)). They follow the same rules:
- They work with `GROUP BY`, `SAMPLE BY`, implicit GROUP BY and `HAVING`.
- They **skip NULL values**. No values give `NULL`.
- They take numbers (`LONG`, `DOUBLE`, `DECIMAL`, ...). Text gives an error. Only `mode`, `percentile_disc`, `entropy` and `approx_count_distinct` also take text and times, and `range` also takes times.
- They give a `DOUBLE`. Use `round(x, 3)` for a report. The exceptions say so in their entry: `mode` and `percentile_disc` give one of the values themselves, `range` keeps the type (`LONG`, `DECIMAL`), `approx_count_distinct` gives a `LONG`.
- With `OVER (...)` they give a moving value for every row, for example a 5-day volatility ([window functions](functions-window.md#aggregates)).
**Spread** means how far the values are from their average. Prices that jump up and down have a big spread; a steady price has a small one. Traders call the spread of price changes **volatility** (risk).
- The **variance** is the average of the squared distances from the average.
- The **standard deviation** is the square root of the variance. It has the same unit as the values (dollars for prices), so it is easier to read.
**Sample or population?**
- **Population:** your rows are *all* the values there are (all trades of one day). Divide by `n`, the number of values: `stddev_pop`, `var_pop`.
- **Sample:** your rows are only *a part* of a bigger story (the last 20 days of a share that trades for years). Divide by `n - 1`: `stddev`, `stddev_samp`, `variance`, `var_samp`. This is the usual choice for prices and returns, and what Excel's `STDEV` does.
With many rows the two are almost the same. With one row, the sample versions give `NULL` (you cannot guess a spread from one value) and the population versions give `0`.
**More on this page:** the **shape** of the values (`skewness`, `kurtosis`), other kinds of average (`geomean`, `harmonic_mean`, `mode`), exact percentiles (`percentile_cont`, `percentile_disc`, `WITHIN GROUP`), spread that big outliers do not move (`mad`, `iqr`), and `range`, `sum_of_squares`, `entropy`, `approx_count_distinct`. For two series together (correlation, beta) see [correlation and regression](functions-correlation.md); for returns, drawdown and the Sharpe ratio see [returns and risk](functions-returns-risk.md).
**Example: Which share moved most? Volatility of the daily % change since 23 August** (sample data: sample)
```sql
SELECT symbol,
count(chg) AS days,
round(avg(chg), 3) AS avg_change_pct,
round(stddev(chg), 3) AS volatility_pct
FROM (SELECT symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS chg
FROM ochl_day
WHERE time >= '2026-08-23') t
GROUP BY symbol
ORDER BY volatility_pct DESC;
```
Result:
| symbol (SYMBOL) | days (LONG) | avg_change_pct (DOUBLE) | volatility_pct (DOUBLE) |
|---|---|---|---|
| VOLT | 14 | 0.409 | 1.885 |
| COAST | 14 | -0.271 | 1.145 |
| CITY | 14 | -0.04 | 0.746 |
(3 rows)
## stddev, stddev_samp
Syntax:
```sql
stddev(x)
stddev_samp(x)
```
The **sample** standard deviation: the square root of `variance(x)`. It divides by `n - 1`. `stddev_samp` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. With fewer than 2 values the result is `NULL`.
- **Notes:** the same as PostgreSQL and QuestDB `stddev`. About two thirds of normal values lie within one standard deviation of the average.
**Example: Spread of the close price per symbol in September** (sample data: sample)
```sql
SELECT symbol, count(*) AS days, round(avg(close), 2) AS avg_close,
round(stddev(close), 3) AS stddev_close, round(stddev_samp(close), 3) AS same
FROM ochl_day
WHERE time IN '2026-09'
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | days (LONG) | avg_close (DOUBLE) | stddev_close (DOUBLE) | same (DOUBLE) |
|---|---|---|---|---|
| CITY | 8 | 500.18 | 3.317 | 3.317 |
| COAST | 8 | 374.73 | 4.534 | 4.534 |
| VOLT | 8 | 179.3 | 7.554 | 7.554 |
(3 rows)
**Example: Moving 5-day spread of CITY (with OVER)** (sample data: sample)
```sql
SELECT time, close,
round(stddev(close) OVER (ORDER BY time ROWS 4 PRECEDING), 3) AS stddev_5d
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-01'
ORDER BY time;
```
Result:
| time (TIMESTAMP) | close (DOUBLE) | stddev_5d (DOUBLE) |
|---|---|---|
| 2026-09-01T00:00:00.000Z | 502.4 | NULL |
| 2026-09-02T00:00:00.000Z | 496.4 | 4.243 |
| 2026-09-03T00:00:00.000Z | 497.9 | 3.122 |
| 2026-09-06T00:00:00.000Z | 499 | 2.55 |
| 2026-09-07T00:00:00.000Z | 498.7 | 2.211 |
| 2026-09-08T00:00:00.000Z | 497.5 | 1.032 |
| 2026-09-09T00:00:00.000Z | 504.6 | 2.892 |
| 2026-09-10T00:00:00.000Z | 504.9 | 3.525 |
(8 rows)
**Example: One value is not enough for a sample: NULL** (sample data: sample)
```sql
SELECT buyer, count(*) AS trades, stddev(rate) AS sample, stddev_pop(rate) AS population
FROM trade_log
WHERE symbol = 'VOLT'
GROUP BY buyer
ORDER BY buyer;
```
Result:
| buyer (INT) | trades (LONG) | sample (DOUBLE) | population (DOUBLE) |
|---|---|---|---|
| 1 | 2 | 2.12132034355964 | 1.5 |
| 34 | 1 | NULL | 0 |
| 58 | 1 | NULL | 0 |
(3 rows)
## stddev_pop
Syntax:
```sql
stddev_pop(x)
```
The **population** standard deviation: the square root of `var_pop(x)`. It divides by `n`.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. One value gives `0`; no values give `NULL`.
- **Notes:** always a little smaller than `stddev` on the same values.
**Example: Sample and population on the same four numbers**
```sql
SELECT stddev(x) AS sample, stddev_pop(x) AS population, avg(x) AS average
FROM (SELECT 2 AS x UNION ALL SELECT 4 UNION ALL SELECT 4 UNION ALL SELECT 6) t;
```
Result:
| sample (DOUBLE) | population (DOUBLE) | average (DOUBLE) |
|---|---|---|
| 1.63299316185545 | 1.4142135623731 | 4 |
(1 row)
**Example: Spread of the trade rate of all trades of one day** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, min(rate) AS low, max(rate) AS high,
round(stddev_pop(rate), 3) AS rate_spread
FROM trade_log
WHERE date IN '2026-09-10'
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | trades (LONG) | low (DOUBLE) | high (DOUBLE) | rate_spread (DOUBLE) |
|---|---|---|---|---|
| CITY | 7 | 514.5 | 518 | 1.102 |
| COAST | 9 | 398 | 402 | 1.227 |
| TELCO | 4 | 878 | 883 | 1.785 |
| VOLT | 4 | 184 | 187 | 1.225 |
(4 rows)
## variance, var_samp
Syntax:
```sql
variance(x)
var_samp(x)
```
The **sample** variance: the sum of the squared distances from the average, divided by `n - 1`. `var_samp` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`. Its unit is the square of the values' unit (dollars²), so `stddev` is easier to read.
- **NULL:** NULL values are skipped. With fewer than 2 values the result is `NULL`.
- **Notes:** `sqrt(variance(x))` is `stddev(x)`.
**Example: Variance and its square root** (sample data: sample)
```sql
SELECT symbol,
round(variance(close), 3) AS variance,
round(var_samp(close), 3) AS same,
round(sqrt(variance(close)), 3) AS sqrt_variance,
round(stddev(close), 3) AS stddev
FROM ochl_day
WHERE time IN '2026-09'
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | variance (DOUBLE) | same (DOUBLE) | sqrt_variance (DOUBLE) | stddev (DOUBLE) |
|---|---|---|---|---|
| CITY | 10.999 | 10.999 | 3.317 | 3.317 |
| COAST | 20.556 | 20.556 | 4.534 | 4.534 |
| VOLT | 57.063 | 57.063 | 7.554 | 7.554 |
(3 rows)
**Example: Text is not a number** (sample data: sample; shows an error)
```sql
SELECT variance(symbol) FROM ochl_day;
```
Result:
Error: `line 1, column 8: variance needs numbers, got 'CITY'`
## var_pop
Syntax:
```sql
var_pop(x)
```
The **population** variance: the sum of the squared distances from the average, divided by `n`.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. One value gives `0`; no values give `NULL`.
**Example: Population and sample variance of daily volume** (sample data: sample)
```sql
SELECT symbol, count(*) AS days,
round(var_pop(volume)) AS var_pop,
round(variance(volume)) AS var_samp
FROM ochl_day
WHERE time IN '2026-09'
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | days (LONG) | var_pop (DOUBLE) | var_samp (DOUBLE) |
|---|---|---|---|
| CITY | 8 | 2308808698 | 2638638513 |
| COAST | 8 | 2403607398 | 2746979884 |
| VOLT | 8 | 1939635473 | 2216726255 |
(3 rows)
**Example: One value: population variance 0, sample variance NULL**
```sql
SELECT var_pop(x) AS population, var_samp(x) AS sample
FROM (SELECT 512.5 AS x) t;
```
Result:
| population (DOUBLE) | sample (STRING) |
|---|---|
| 0 | NULL |
(1 row)
## skewness, skew
Syntax:
```sql
skewness(x)
skew(x)
```
The **skewness** tells if the values lean to one side. A normal "bell curve" has skewness `0`: big values above and below the average are equally common.
- **Negative skewness:** the long tail is on the low side. For returns: most days are small gains, but now and then there is a **big fall**. This is crash risk.
- **Positive skewness:** the long tail is on the high side: now and then a big jump up (like a lottery).
It is the **sample** skewness, the same as Excel `SKEW`. `skew` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values, usually returns |
- **Returns:** `DOUBLE`. About `-0.5` to `0.5` is nearly balanced.
- **NULL:** NULL values are skipped. `NULL` with fewer than 3 values, and when all values are the same.
- **Notes:** a few very big values change it a lot, so use many values (hundreds of returns). `skewness_pop` is the version without the small-sample correction.
**Example: Shape of the one-minute returns of CITY and COAST** (sample data: sample)
```sql
SELECT symbol, count(r) AS returns,
round(skewness(r), 3) AS skewness, round(kurtosis(r), 3) AS kurtosis,
round(min(r), 4) AS worst_pct, round(max(r), 4) AS best_pct
FROM (SELECT symbol, ln(close / lag(close) OVER (PARTITION BY symbol ORDER BY time)) * 100 AS r
FROM ochl_minutes) t
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | returns (LONG) | skewness (DOUBLE) | kurtosis (DOUBLE) | worst_pct (DOUBLE) | best_pct (DOUBLE) |
|---|---|---|---|---|---|
| CITY | 479 | -0.258 | -0.905 | -0.1197 | 0.0998 |
| COAST | 475 | -0.195 | -1.01 | -0.1605 | 0.1336 |
(2 rows)
**Example: One big value makes a long tail to the right: positive skewness**
```sql
SELECT round(skewness(x), 3) AS skewness, round(skew(x), 3) AS same, round(avg(x), 2) AS average, median(x) AS median
FROM (SELECT 1 AS x UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 10) t;
```
Result:
| skewness (DOUBLE) | same (DOUBLE) | average (DOUBLE) | median (DOUBLE) |
|---|---|---|---|
| 2.197 | 2.197 | 3.17 | 2 |
(1 row)
## skewness_pop
Syntax:
```sql
skewness_pop(x)
```
The **population** skewness: the same idea as `skewness`, without the correction for a small number of values. It is always a little closer to 0 than `skewness`.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. `NULL` with no values, and when all values are the same (also one value). Two different values give `0`.
- **Notes:** use it when your rows are all the values there are; for returns, `skewness` is the usual choice.
**Example: Daily returns since 23 August: sample and population versions** (sample data: sample)
```sql
SELECT symbol, count(r) AS days,
round(skewness(r), 3) AS skewness, round(skewness_pop(r), 3) AS skewness_pop
FROM (SELECT symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r
FROM ochl_day
WHERE time >= '2026-08-23') t
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | days (LONG) | skewness (DOUBLE) | skewness_pop (DOUBLE) |
|---|---|---|---|
| CITY | 14 | -0.17 | -0.151 |
| COAST | 14 | 0.099 | 0.088 |
| VOLT | 14 | 0.178 | 0.158 |
(3 rows)
**Example: Two values: skewness needs 3, skewness_pop gives 0**
```sql
SELECT skewness(x) AS skewness, skewness_pop(x) AS skewness_pop
FROM (SELECT 1 AS x UNION ALL SELECT 2) t;
```
Result:
| skewness (STRING) | skewness_pop (DOUBLE) |
|---|---|
| NULL | 0 |
(1 row)
## kurtosis, kurt
Syntax:
```sql
kurtosis(x)
kurt(x)
```
The **kurtosis** tells how often **very big values** come, compared with a normal bell curve. KarelaDB gives the **excess** kurtosis: a normal curve has `0`.
- **Positive kurtosis ("fat tails"):** very big moves, up or down, happen more often than a bell curve says. Most share returns are like this: a model that expects a normal curve under-guesses the risk of a crash day.
- **Negative kurtosis:** the values stay inside a band; very big moves are rare.
It is the **sample** excess kurtosis, the same as Excel `KURT`. `kurt` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values, usually returns |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. `NULL` with fewer than 4 values, and when all values are the same.
- **Notes:** like skewness, it needs many values to mean something. `kurtosis_pop` is the version without the small-sample correction.
**Example: Fat tails? Kurtosis of the daily returns of every symbol** (sample data: sample)
```sql
SELECT symbol, count(r) AS days, round(kurtosis(r), 3) AS kurtosis, round(kurt(r), 3) AS same
FROM (SELECT symbol, pct_change(close) OVER (PARTITION BY symbol ORDER BY time) AS r
FROM ochl_day
WHERE time >= '2026-08-23') t
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | days (LONG) | kurtosis (DOUBLE) | same (DOUBLE) |
|---|---|---|---|
| CITY | 14 | 0.349 | 0.349 |
| COAST | 14 | -1.705 | -1.705 |
| VOLT | 14 | -1.753 | -1.753 |
(3 rows)
**Example: One value far away from the others: big kurtosis**
```sql
SELECT round(kurtosis(x), 3) AS kurtosis, round(kurtosis_pop(x), 3) AS kurtosis_pop
FROM (SELECT 1 AS x UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 10) t;
```
Result:
| kurtosis (DOUBLE) | kurtosis_pop (DOUBLE) |
|---|---|
| 5.015 | 0.862 |
(1 row)
## kurtosis_pop
Syntax:
```sql
kurtosis_pop(x)
```
The **population** excess kurtosis: the same idea as `kurtosis`, without the correction for a small number of values.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`. Never below `-2`.
- **NULL:** NULL values are skipped. `NULL` with no values, and when all values are the same.
**Example: Three values: kurtosis needs 4, kurtosis_pop works**
```sql
SELECT kurtosis(x) AS kurtosis, kurtosis_pop(x) AS kurtosis_pop
FROM (SELECT 1 AS x UNION ALL SELECT 2 UNION ALL SELECT 3) t;
```
Result:
| kurtosis (STRING) | kurtosis_pop (DOUBLE) |
|---|---|
| NULL | -1.5 |
(1 row)
## geomean, geometric_mean
Syntax:
```sql
geomean(x)
geometric_mean(x)
```
The **geometric mean**: multiply all `n` values, then take the n-th root. It is the right average for **growth factors** (1.10 for +10%). If a price goes +10% and then -10%, the plain average of the changes is 0%, but you lost money: the geometric mean of 1.10 and 0.90 is 0.995, a loss of 0.5% per period. `geometric_mean` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number, more than 0 | the values, for example `nav / previous nav` |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. No values give `NULL`. If **any** value is `0` or less, the result is `NULL`.
- **Notes:** for returns, use growth factors (`1 + return`), take the geometric mean, then subtract 1. It is always at or below `avg(x)`.
**Example: +10% then -10%: the average says 0, the geometric mean says a loss**
```sql
SELECT round(avg(g), 5) AS avg_factor, round(geomean(g), 5) AS geomean_factor
FROM (SELECT 1.10 AS g UNION ALL SELECT 0.90) t;
```
Result:
| avg_factor (DOUBLE) | geomean_factor (DOUBLE) |
|---|---|
| 1 | 0.99499 |
(1 row)
**Example: True average monthly growth of two funds** (sample data: sample, analysis-market)
```sql
SELECT fund, count(g) AS months,
round(avg(g) - 1, 5) AS avg_growth,
round(geometric_mean(g) - 1, 5) AS true_avg_growth
FROM (SELECT fund, nav / lag(nav) OVER (PARTITION BY fund ORDER BY time) AS g
FROM fund_nav) t
GROUP BY fund
ORDER BY fund;
```
Result:
| fund (SYMBOL) | months (LONG) | avg_growth (DOUBLE) | true_avg_growth (DOUBLE) |
|---|---|---|---|
| GROWTH | 23 | 0.00886 | 0.00861 |
| INCOME | 23 | 0.00328 | 0.00327 |
(2 rows)
**Example: A 0 or a negative value gives NULL**
```sql
SELECT geomean(x) AS geomean
FROM (SELECT 2 AS x UNION ALL SELECT 0 UNION ALL SELECT 8) t;
```
Result:
| geomean (STRING) |
|---|
| NULL |
(1 row)
## harmonic_mean
Syntax:
```sql
harmonic_mean(x)
```
The **harmonic mean**: `n / sum(1 / x)`. It is the right average for **rates and prices when the money is fixed**. If you invest the same amount every month (a SIP), you buy more units when the price is low, and your **average cost per unit** is the harmonic mean of the prices. It is also the usual average of P/E ratios.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number, more than 0 | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. No values give `NULL`. If **any** value is `0` or less, the result is `NULL`.
- **Notes:** always at or below `geomean(x)`, which is at or below `avg(x)`.
**Example: Average cost per unit with the same money every month for a year** (sample data: sample, analysis-market)
```sql
SELECT fund, count(*) AS months,
round(harmonic_mean(nav), 4) AS avg_cost_per_unit,
round(avg(nav), 4) AS avg_nav
FROM fund_nav
WHERE time >= '2025-09-01'
GROUP BY fund
ORDER BY fund;
```
Result:
| fund (SYMBOL) | months (LONG) | avg_cost_per_unit (DOUBLE) | avg_nav (DOUBLE) |
|---|---|---|---|
| GROWTH | 12 | 11.4256 | 11.4375 |
| INCOME | 12 | 10.5647 | 10.5658 |
(2 rows)
**Example: Average P/E of three shares**
```sql
SELECT round(harmonic_mean(pe), 2) AS harmonic, round(geomean(pe), 2) AS geometric, round(avg(pe), 2) AS plain
FROM (SELECT 10 AS pe UNION ALL SELECT 20 UNION ALL SELECT 40) t;
```
Result:
| harmonic (DOUBLE) | geometric (DOUBLE) | plain (DOUBLE) |
|---|---|---|
| 17.14 | 20 | 23.33 |
(1 row)
## mode
Syntax:
```sql
mode(x)
mode() WITHIN GROUP (ORDER BY x)
```
The **most common value**: the value that comes in the most rows. It works for numbers, text and times.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any type | the values |
- **Returns:** the same type as `x`: one of the values.
- **NULL:** NULL values are skipped (they are never the answer). No values give `NULL`.
- **Notes:** when two values are equally common, the **smallest** one wins (for text: first in alphabet order). Equal numbers of different types count as one value (`1` and `1.0`). The `WITHIN GROUP` form is the PostgreSQL way to write the same thing ([WITHIN GROUP](#within-group)).
**Example: Most common trade size of each symbol** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, mode(quantity) AS most_common_quantity
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | trades (LONG) | most_common_quantity (LONG) |
|---|---|---|
| CITY | 11 | 500 |
| COAST | 11 | 20 |
| TELCO | 6 | 1000 |
| VOLT | 4 | 50 |
(4 rows)
**Example: City with the most app users (Kathmandu and Pokhara tie: the smallest wins)** (sample data: query-app)
```sql
SELECT mode(city) AS top_city, count(city) AS users_with_city
FROM app_users;
SELECT city, count(*) AS users FROM app_users WHERE city IS NOT NULL GROUP BY city ORDER BY users DESC, city;
```
Result:
`SELECT mode(city) AS top_city, count(city) AS users_with_city FROM app_users`
| top_city (STRING) | users_with_city (LONG) |
|---|---|
| Kathmandu | 6 |
(1 row)
`SELECT city, count(*) AS users FROM app_users WHERE city IS NOT NULL GROUP BY city ORDER BY users DE`
| city (STRING) | users (LONG) |
|---|---|
| Kathmandu | 2 |
| Pokhara | 2 |
| Dharan | 1 |
| Lalitpur | 1 |
(4 rows)
## percentile_cont, quantile_cont
Syntax:
```sql
percentile_cont(x, fraction)
percentile_cont(fraction) WITHIN GROUP (ORDER BY x)
quantile_cont(x, fraction)
```
The **continuous percentile**: the value below which the part `fraction` of the values lie. `percentile_cont(x, 0.9)` is the value that 90% of the values are at or below. Between two values it goes the right part of the way from one to the other (linear interpolation), so the answer may be a value that is not in the data. `quantile_cont` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
| `fraction` | number from `0` to `1` | the part: `0.5` is the median, `0.9` the 90th percentile |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values of `x` are skipped. No values give `NULL`. A NULL `fraction` gives `NULL`.
- **Notes:** the same numbers as [percentile](functions-aggregate.md#percentile) and PostgreSQL `percentile_cont`, but strict: `fraction` must be from 0 to 1 (`90` is an error, not 90%). `fraction` is read once, from the first row. A `DOUBLE` result can show a tiny rounding rest (`950.000000000001`): use `round`.
**Example: Big trades: 90th percentile of the quantity per symbol** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades,
round(percentile_cont(quantity, 0.5), 1) AS median_qty,
round(percentile_cont(quantity, 0.9), 1) AS p90_qty,
max(quantity) AS biggest
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | trades (LONG) | median_qty (DOUBLE) | p90_qty (DOUBLE) | biggest (LONG) |
|---|---|---|---|---|
| CITY | 11 | 100 | 500 | 500 |
| COAST | 11 | 50 | 1000 | 1000 |
| TELCO | 6 | 350 | 1000 | 1000 |
| VOLT | 4 | 75 | 170 | 200 |
(4 rows)
**Example: The same with the SQL standard spelling** (sample data: sample)
```sql
SELECT round(percentile_cont(0.9) WITHIN GROUP (ORDER BY quantity), 1) AS p90,
round(quantile_cont(quantity, 0.9), 1) AS same
FROM trade_log;
```
Result:
| p90 (DOUBLE) | same (DOUBLE) |
|---|---|
| 950 | 950 |
(1 row)
**Example: The fraction must be from 0 to 1** (sample data: sample; shows an error)
```sql
SELECT percentile_cont(rate, 90) FROM trade_log;
```
Result:
Error: `line 1, column 8: percentile_cont: the fraction must be between 0 and 1, like 0.9, got 90`
## percentile_disc, quantile_disc
Syntax:
```sql
percentile_disc(x, fraction)
percentile_disc(fraction) WITHIN GROUP (ORDER BY x)
quantile_disc(x, fraction)
```
The **discrete percentile**: the first value, in sorted order, at or above which the part `fraction` of the values lie. Unlike `percentile_cont`, the answer is always **one of the values**, never a value in between. So it also works for text and times. `quantile_disc` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any type that can be sorted | the values: numbers, text, times |
| `fraction` | number from `0` to `1` | the part; `0` gives the smallest value, `1` the biggest |
- **Returns:** the same type as `x`.
- **NULL:** NULL values of `x` are skipped. No values give `NULL`. A NULL `fraction` gives `NULL`.
- **Notes:** with `n` values it takes the value at place `ceil(fraction × n)` in sorted order. Values that cannot be compared with each other (a number and a text) give an error.
**Example: Discrete and continuous 25th percentile of the trade rate** (sample data: sample)
```sql
SELECT symbol,
percentile_disc(rate, 0.25) AS p25_disc,
percentile_cont(rate, 0.25) AS p25_cont
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | p25_disc (DOUBLE) | p25_cont (DOUBLE) |
|---|---|---|
| CITY | 514.5 | 514.75 |
| COAST | 398 | 398.25 |
| TELCO | 879 | 879.5 |
| VOLT | 184 | 184 |
(4 rows)
**Example: The middle sign-up time of the app users (a TIMESTAMP)** (sample data: query-app)
```sql
SELECT percentile_disc(created_at, 0.5) AS middle_signup, quantile_disc(username, 0.5) AS middle_username
FROM app_users;
```
Result:
| middle_signup (TIMESTAMP) | middle_username (STRING) |
|---|---|
| 2026-03-20T13:00:00.000Z | hari_99 |
(1 row)
## WITHIN GROUP (ORDER BY ...)
Syntax:
```sql
percentile_cont(fraction) WITHIN GROUP (ORDER BY x)
percentile_disc(fraction) WITHIN GROUP (ORDER BY x)
mode() WITHIN GROUP (ORDER BY x)
```
The SQL standard (PostgreSQL) way to write `percentile_cont`, `percentile_disc` and `mode`. KarelaDB reads `percentile_cont(0.9) WITHIN GROUP (ORDER BY x)` as `percentile_cont(x, 0.9)`, so both spellings give the same result. It helps when you copy SQL from PostgreSQL.
| Part | Meaning |
|---|---|
| `fraction` | the part from `0` to `1` (not for `mode`, which has empty `()`) |
| `ORDER BY x` | the values; exactly one, in ascending order |
- **Works with:** `percentile_cont`, `percentile_disc`, `quantile_cont`, `quantile_disc` and `mode`. Other functions give an error.
- **Notes:** `DESC` is not accepted: for the 90th percentile from the top, use `1 - 0.9 = 0.1`. Several values in `ORDER BY` are not accepted.
**Example: Volume of CITY's one-minute candles on 10 September** (sample data: sample)
```sql
SELECT percentile_cont(0.9) WITHIN GROUP (ORDER BY volume) AS p90,
percentile_disc(0.9) WITHIN GROUP (ORDER BY volume) AS p90_disc,
mode() WITHIN GROUP (ORDER BY volume) AS most_common
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10';
```
Result:
| p90 (DOUBLE) | p90_disc (DOUBLE) | most_common (DOUBLE) |
|---|---|---|
| 1341 | 1340 | 880 |
(1 row)
**Example: DESC is not accepted: use 1 - fraction** (sample data: sample; shows an error)
```sql
SELECT percentile_cont(0.1) WITHIN GROUP (ORDER BY rate DESC) FROM trade_log;
```
Result:
Error: `line 1, column 52: WITHIN GROUP needs exactly one ORDER BY value in ascending order (for DESC use 1 - fraction)`
**Example: Only percentiles and mode have WITHIN GROUP** (sample data: sample; shows an error)
```sql
SELECT avg(quantity) WITHIN GROUP (ORDER BY quantity) FROM trade_log;
```
Result:
Error: `line 1, column 22: WITHIN GROUP works with percentile_cont(fraction), percentile_disc(fraction) and mode(); write percentile_cont(0.5) WITHIN GROUP (ORDER BY x)`
## median_abs_deviation, mad
Syntax:
```sql
median_abs_deviation(x)
mad(x)
```
The **median absolute deviation (MAD)**: first find the median, then the distance of every value from it, then the median of those distances. It is a measure of spread, like `stddev`, but **robust**: one huge trade or one wrong price hardly moves it. So it is good to find **outliers** (values far away from the rest). `mad` is the same function.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. No values give `NULL`.
- **Notes:** the raw MAD, without a scale factor. For values from a bell curve, `1.4826 × mad(x)` is about `stddev(x)`.
**Example: Spread of the trade size: stddev against the robust measures** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, median(quantity) AS median_qty,
mad(quantity) AS mad_qty, iqr(quantity) AS iqr_qty, round(stddev(quantity), 1) AS stddev_qty
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | trades (LONG) | median_qty (DOUBLE) | mad_qty (DOUBLE) | iqr_qty (DOUBLE) | stddev_qty (DOUBLE) |
|---|---|---|---|---|---|
| CITY | 11 | 100 | 90 | 480 | 228.3 |
| COAST | 11 | 50 | 40 | 330 | 384.6 |
| TELCO | 6 | 350 | 315 | 787.5 | 450.4 |
| VOLT | 4 | 75 | 25 | 75 | 70.7 |
(4 rows)
**Example: Unusual trades: more than 5 MADs above the median of their symbol** (sample data: sample)
```sql
SELECT f.transaction_id, f.symbol, f.quantity, s.median_qty, s.mad_qty
FROM trade_log f
JOIN (SELECT symbol, median(quantity) AS median_qty, median_abs_deviation(quantity) AS mad_qty
FROM trade_log
GROUP BY symbol) s ON f.symbol = s.symbol
WHERE f.quantity > s.median_qty + 5 * s.mad_qty
ORDER BY f.transaction_id;
```
Result:
| transaction_id (LONG) | symbol (SYMBOL) | quantity (LONG) | median_qty (DOUBLE) | mad_qty (DOUBLE) |
|---|---|---|---|---|
| 100015 | COAST | 1000 | 50 | 40 |
| 100023 | COAST | 500 | 50 | 40 |
| 100031 | COAST | 1000 | 50 | 40 |
(3 rows)
## iqr
Syntax:
```sql
iqr(x)
```
The **interquartile range**: the 75th percentile minus the 25th percentile. The middle half of all values lies in this band. Like `mad`, the biggest and smallest values do not change it.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. No values give `NULL`.
- **Notes:** the same as `percentile_cont(x, 0.75) - percentile_cont(x, 0.25)`. A common outlier rule: below `Q1 - 1.5 × iqr` or above `Q3 + 1.5 × iqr` (this is the box plot rule).
**Example: Middle half of the one-minute volume on 10 September** (sample data: sample)
```sql
SELECT symbol,
percentile_cont(volume, 0.25) AS q1,
percentile_cont(volume, 0.75) AS q3,
iqr(volume) AS iqr
FROM ochl_minutes
WHERE time IN '2026-09-10'
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | q1 (DOUBLE) | q3 (DOUBLE) | iqr (DOUBLE) |
|---|---|---|---|
| CITY | 390 | 1112.5 | 722.5 |
| COAST | 400 | 1192.5 | 792.5 |
(2 rows)
## range
Syntax:
```sql
range(x)
range(x) OVER (...)
```
The biggest value minus the smallest: `max(x) - min(x)`. For prices it is the **high-low range**: how far the price travelled.
| Argument | Type | Meaning |
|---|---|---|
| `x` | number, `TIMESTAMP` or `DATE` | the values |
- **Returns:** `LONG` for whole numbers, `DECIMAL` for `DECIMAL`, `DOUBLE` for other numbers. For `TIMESTAMP` values: the milliseconds between the first and the last time (`LONG`); for `DATE` values: the number of days (`LONG`).
- **NULL:** NULL values are skipped. No values give `NULL`.
- **Notes:** text gives the error `needs numbers or times`. The name is also a keyword of window frames (`RANGE BETWEEN`); as a function, `range(x)` works as usual.
**Example: Price range and time span of each symbol** (sample data: sample)
```sql
SELECT symbol, range(close) AS close_range, range(time) / 86400000 AS days_between_first_and_last
FROM ochl_day
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | close_range (DOUBLE) | days_between_first_and_last (DOUBLE) |
|---|---|---|
| CITY | 46 | 256 |
| COAST | 59.4 | 256 |
| VOLT | 27.4 | 256 |
(3 rows)
**Example: Moving 5-day range of the close (with OVER)** (sample data: sample)
```sql
SELECT time, close, round(range(close) OVER (ORDER BY time ROWS 4 PRECEDING), 2) AS range_5d
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-01'
ORDER BY time;
```
Result:
| time (TIMESTAMP) | close (DOUBLE) | range_5d (DOUBLE) |
|---|---|---|
| 2026-09-01T00:00:00.000Z | 502.4 | 0 |
| 2026-09-02T00:00:00.000Z | 496.4 | 6 |
| 2026-09-03T00:00:00.000Z | 497.9 | 6 |
| 2026-09-06T00:00:00.000Z | 499 | 6 |
| 2026-09-07T00:00:00.000Z | 498.7 | 6 |
| 2026-09-08T00:00:00.000Z | 497.5 | 2.6 |
| 2026-09-09T00:00:00.000Z | 504.6 | 7.1 |
| 2026-09-10T00:00:00.000Z | 504.9 | 7.4 |
(8 rows)
**Example: DATE values give days** (sample data: fn-dates)
```sql
SELECT min(listed_on) AS oldest, max(listed_on) AS newest, range(listed_on) AS days_between
FROM listings;
```
Result:
| oldest (DATE) | newest (DATE) | days_between (LONG) |
|---|---|---|
| 1986-01-01 | 2019-08-28 | 12292 |
(1 row)
## sum_of_squares
Syntax:
```sql
sum_of_squares(x)
```
Adds up the square of every value: `x₁² + x₂² + ...`. On intraday log returns, the square root of this sum is the **realized volatility** of the day: how much the price really moved, minute by minute.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any number | the values |
- **Returns:** `DOUBLE`.
- **NULL:** NULL values are skipped. No values give `NULL`.
- **Notes:** it squares the values themselves, not their distance from the average (that is `regr_syy` or `var_pop(x) × count(x)`).
**Example: Realized volatility of each day from one-minute log returns (in %)** (sample data: sample)
```sql
SELECT time, symbol, count(r) AS returns, round(sqrt(sum_of_squares(r)), 4) AS realized_vol_pct
FROM (SELECT time, symbol,
ln(close / lag(close) OVER (PARTITION BY symbol, date_trunc('day', time) ORDER BY time)) * 100 AS r
FROM ochl_minutes) t
SAMPLE BY 1d
ORDER BY symbol, time;
```
Result:
| time (TIMESTAMP) | symbol (SYMBOL) | returns (LONG) | realized_vol_pct (DOUBLE) |
|---|---|---|---|
| 2026-09-09T00:00:00.000Z | CITY | 239 | 1.0082 |
| 2026-09-10T00:00:00.000Z | CITY | 239 | 1.022 |
| 2026-09-09T00:00:00.000Z | COAST | 239 | 1.4068 |
| 2026-09-10T00:00:00.000Z | COAST | 235 | 1.4162 |
(4 rows)
**Example: NULL values are skipped**
```sql
SELECT sum_of_squares(x) AS sum_of_squares
FROM (SELECT 1.5 AS x UNION ALL SELECT NULL UNION ALL SELECT -2) t;
```
Result:
| sum_of_squares (DOUBLE) |
|---|
| 6.25 |
(1 row)
## entropy
Syntax:
```sql
entropy(x)
```
The **Shannon entropy** of the values, in bits: how **evenly** the rows are spread over the different values. `0` means every row has the same value. The biggest entropy comes when every value is equally common: with `k` different values it is `log2(k)` (2 values: 1, 4 values: 2). Use it to see if the trades of a share come from many brokers or from a few.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any type | the values: numbers, text, times |
- **Returns:** `DOUBLE`, `0` or more.
- **NULL:** NULL values are skipped. No values give `NULL`.
- **Notes:** it is `-sum(p × log2(p))`, where `p` is the share of rows with each value.
**Example: Are the buyers of each share spread out or few?** (sample data: sample)
```sql
SELECT symbol, count(*) AS trades, approx_count_distinct(buyer) AS buyers,
round(entropy(buyer), 3) AS buyer_entropy, round(log2(approx_count_distinct(buyer)), 3) AS most_possible
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | trades (LONG) | buyers (LONG) | buyer_entropy (DOUBLE) | most_possible (DOUBLE) |
|---|---|---|---|---|
| CITY | 11 | 4 | 1.79 | 2 |
| COAST | 11 | 5 | 2.187 | 2.322 |
| TELCO | 6 | 5 | 2.252 | 2.322 |
| VOLT | 4 | 3 | 1.5 | 1.585 |
(4 rows)
**Example: How evenly are the app users spread over the plans?** (sample data: query-app)
```sql
SELECT round(entropy(plan), 3) AS plan_entropy, approx_count_distinct(plan) AS plans, count(*) AS users
FROM app_users;
```
Result:
| plan_entropy (DOUBLE) | plans (LONG) | users (LONG) |
|---|---|---|
| 1.406 | 3 | 8 |
(1 row)
**Example: All rows the same: entropy 0**
```sql
SELECT entropy(x) AS entropy
FROM (SELECT 'CITY' AS x UNION ALL SELECT 'CITY') t;
```
Result:
| entropy (DOUBLE) |
|---|
| 0 |
(1 row)
## approx_count_distinct
Syntax:
```sql
approx_count_distinct(x)
```
The number of **different** values of `x`. Other databases (DuckDB, ClickHouse, BigQuery) guess this number to save memory. In KarelaDB today the count is **exact**: it gives the same result as `count(DISTINCT x)`. The name exists so SQL from those databases works.
| Argument | Type | Meaning |
|---|---|---|
| `x` | any type | the values |
- **Returns:** `LONG`.
- **NULL:** NULL values are not counted. No values give `0`.
- **Notes:** equal numbers of different types count as one value (`1` and `1.0`). KarelaDB keeps every different value in memory while it counts.
**Example: Different buyers and sellers of each share** (sample data: sample)
```sql
SELECT symbol, approx_count_distinct(buyer) AS buyers, count(DISTINCT buyer) AS same,
approx_count_distinct(seller) AS sellers
FROM trade_log
GROUP BY symbol
ORDER BY symbol;
```
Result:
| symbol (SYMBOL) | buyers (LONG) | same (LONG) | sellers (LONG) |
|---|---|---|---|
| CITY | 4 | 4 | 5 |
| COAST | 5 | 5 | 5 |
| TELCO | 5 | 5 | 3 |
| VOLT | 3 | 3 | 4 |
(4 rows)
**Example: No rows: 0 (entropy and mode give NULL)** (sample data: sample)
```sql
SELECT approx_count_distinct(buyer) AS buyers, entropy(buyer) AS entropy, mode(buyer) AS mode
FROM trade_log
WHERE symbol = 'NONE';
```
Result:
| buyers (LONG) | entropy (STRING) | mode (STRING) |
|---|---|---|
| 0 | NULL | NULL |
(1 row)