KDB knowledge

Window and series functions

Look at a series of rows: previous and next values, running totals, moving windows, time windows and ranks, with OVER (...).

A window function (or series function) gives a value for every row, and it may look at other rows to make it: the row before, the rows of the same symbol, the last 5 rows. Unlike GROUP BY, the rows stay; nothing is merged.

The order of the series. Without OVER, or with OVER but without ORDER BY, the series is all result rows in time order (the time column of the table). A table or subquery without a time column keeps the order the rows come in. Rows with the same time keep their storage order.

Rules

  1. WHERE runs first: the series only has the rows that WHERE keeps. LIMIT and ORDER BY run after: they do not change the values.
  2. The rows of the result are not sorted in series order by themselves. Add ORDER BY time (or ORDER BY symbol, time) to read them.
  3. Window functions can only be in the select list. To filter on one (WHERE rn = 1), put the query in a subquery.
  4. With GROUP BY or SAMPLE BY in the same SELECT, the window runs over the groups, after they are made: sum(sum(qty)) OVER (ORDER BY time) is a running total of the grouped sums. Inside OVER, use the group keys or aggregates, not the raw columns.
  5. One window function cannot be inside another (lag(sma(close, 3))), and an aggregate cannot take a window function (sum(lag(close) OVER (...))). The error says so (PostgreSQL 42803). Use one subquery level per step: SELECT sum(x) FROM (SELECT lag(close) OVER (...) AS x FROM t) s.

Example: Yesterday's close, change and running volume of VOLT (sample data: sample)

SELECT time, close,
       lag(close) AS prev_close,
       round(close - lag(close), 2) AS change,
       round(pct_change(close), 2) AS change_pct,
       cumsum(volume) AS running_volume
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)prev_close (DOUBLE)change (DOUBLE)change_pct (DOUBLE)running_volume (DOUBLE)
2026-09-06T00:00:00.000Z180.1NULLNULLNULL78550
2026-09-07T00:00:00.000Z176.9180.1-3.2-1.78214110
2026-09-08T00:00:00.000Z182.4176.95.53.11350880
2026-09-09T00:00:00.000Z187.3182.44.92.69445290
2026-09-10T00:00:00.000Z191.2187.33.92.08486760

(5 rows)

Example: A sum of yesterday's closes: the window in a subquery (sample data: sample)

SELECT round(sum(prev_close), 2) AS sum_prev_close
FROM (SELECT lag(close) OVER (ORDER BY time) AS prev_close FROM ochl_day WHERE symbol = 'VOLT') s;

Result:

sum_prev_close (DOUBLE)
3247.3

(1 row)

Example: The same without the subquery is refused (sample data: sample; shows an error)

SELECT sum(lag(close) OVER (ORDER BY time)) FROM ochl_day WHERE symbol = 'VOLT';

Result:

Error: line 1, column 12: 'lag' is a window function and cannot be used inside the aggregate 'sum'; compute it in a subquery first: SELECT sum(x) FROM (SELECT lag(...) OVER (...) AS x FROM t) s

Example: Top trade of each symbol: the window inside, the filter outside (sample data: sample)

SELECT symbol, transaction_id, buyer, amount
FROM (SELECT symbol, transaction_id, buyer, amount,
             row_number() OVER (PARTITION BY symbol ORDER BY amount DESC) AS rn
      FROM trade_log) t
WHERE rn = 1
ORDER BY amount DESC;

Result:

symbol (SYMBOL)transaction_id (LONG)buyer (INT)amount (DOUBLE)
TELCO10000158881000
COAST10003145402000
CITY1000051258500
VOLT1000125836800

(4 rows)

OVER (...)

Syntax:

function(arguments) OVER ()
function(arguments) OVER ([PARTITION BY expr, ...]
                          [ORDER BY expr [ASC | DESC], ...]
                          [ROWS n PRECEDING | ROWS UNBOUNDED PRECEDING
                           | ROWS BETWEEN start AND end
                           | RANGE BETWEEN start AND end])

start, end:  UNBOUNDED PRECEDING | n PRECEDING | CURRENT ROW | n FOLLOWING | UNBOUNDED FOLLOWING

OVER says which rows the function sees for each row: the window. All three parts are optional.

OVER (...)The window of each row
OVER ()all result rows
OVER (PARTITION BY symbol)all rows with the same symbol
OVER (PARTITION BY symbol ORDER BY time)from the first row of that symbol to this row (a running value)
OVER (PARTITION BY symbol ORDER BY time ROWS 4 PRECEDING)this row and the 4 rows before it (a moving value)
OVER (ORDER BY time ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING)2 rows before, this row and 2 rows after (FOLLOWING)
OVER (ORDER BY time RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW)the rows of the last 7 days (RANGE)

Example: The same sum over three different windows (sample data: sample)

SELECT time, volume,
       sum(volume) OVER () AS all_rows,
       sum(volume) OVER (ORDER BY time) AS running,
       sum(volume) OVER (ORDER BY time ROWS 1 PRECEDING) AS last_2_days
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)volume (DOUBLE)all_rows (DOUBLE)running (DOUBLE)last_2_days (DOUBLE)
2026-09-06T00:00:00.000Z749505918607495074950
2026-09-07T00:00:00.000Z117080591860192030192030
2026-09-08T00:00:00.000Z34400591860226430151480
2026-09-09T00:00:00.000Z182390591860408820216790
2026-09-10T00:00:00.000Z183040591860591860365430

(5 rows)

Example: Only series functions and aggregates can have OVER (sample data: sample; shows an error)

SELECT upper(symbol) OVER () FROM ochl_day;

Result:

Error: line 1, column 8: 'upper' cannot be used with OVER

PARTITION BY (window)

Syntax:

function(arguments) OVER (PARTITION BY expr [, expr ...] ...)

Splits the rows into separate series, one per value (for example one per symbol). The function starts again for every part: lag of the first CITY row is NULL, not the last COAST price.

ArgumentTypeMeaning
exprany typea column or expression; rows with equal values are one part

Example: Previous close per symbol (sample data: sample)

SELECT time, symbol, close,
       lag(close) OVER (PARTITION BY symbol ORDER BY time) AS prev_close
FROM ochl_day
WHERE time >= '2026-09-09'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)prev_close (DOUBLE)
2026-09-09T00:00:00.000ZCITY504.6NULL
2026-09-10T00:00:00.000ZCITY504.9504.6
2026-09-09T00:00:00.000ZCOAST380.1NULL
2026-09-10T00:00:00.000ZCOAST376.1380.1
2026-09-09T00:00:00.000ZVOLT187.3NULL
2026-09-10T00:00:00.000ZVOLT191.2187.3

(6 rows)

Example: Wrong: without PARTITION BY the symbols are one series (sample data: sample)

SELECT time, symbol, close, lag(close) AS prev_close_mixed
FROM ochl_day
WHERE time >= '2026-09-09'
ORDER BY time, symbol;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)prev_close_mixed (DOUBLE)
2026-09-09T00:00:00.000ZCITY504.6NULL
2026-09-09T00:00:00.000ZCOAST380.1504.6
2026-09-09T00:00:00.000ZVOLT187.3380.1
2026-09-10T00:00:00.000ZCITY504.9187.3
2026-09-10T00:00:00.000ZCOAST376.1504.9
2026-09-10T00:00:00.000ZVOLT191.2376.1

(6 rows)

Example: Each symbol's share of the day's volume (sample data: sample)

SELECT time, symbol, volume,
       round(volume * 100.0 / sum(volume) OVER (PARTITION BY time), 1) AS share_pct
FROM ochl_day
WHERE time >= '2026-09-09'
ORDER BY time, share_pct DESC;

Result:

time (TIMESTAMP)symbol (SYMBOL)volume (DOUBLE)share_pct (DOUBLE)
2026-09-09T00:00:00.000ZCOAST18633040.2
2026-09-09T00:00:00.000ZCITY18239039.4
2026-09-09T00:00:00.000ZVOLT9441020.4
2026-09-10T00:00:00.000ZCOAST18620045.3
2026-09-10T00:00:00.000ZCITY18304044.6
2026-09-10T00:00:00.000ZVOLT4147010.1

(6 rows)

ORDER BY (window)

Syntax:

function(arguments) OVER ([PARTITION BY ...] ORDER BY expr [ASC | DESC] [, expr [ASC | DESC] ...] ...)

The order of the rows inside the window. It decides what "previous" means for lag, what a running total adds up, and who is first for rank.

Example: Biggest volume first: rank the days of COAST (sample data: sample)

SELECT time, volume, row_number() OVER (ORDER BY volume DESC) AS volume_rank
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)volume (DOUBLE)volume_rank (LONG)
2026-09-06T00:00:00.000Z1436203
2026-09-07T00:00:00.000Z1130104
2026-09-08T00:00:00.000Z609805
2026-09-09T00:00:00.000Z1863301
2026-09-10T00:00:00.000Z1862002

(5 rows)

Example: Running total of the day's turnover, trade by trade (sample data: sample)

SELECT transaction_id, symbol, amount,
       sum(amount) OVER (ORDER BY transaction_id) AS running_turnover
FROM trade_log
WHERE date IN '2026-09-09'
ORDER BY transaction_id;

Result:

transaction_id (LONG)symbol (SYMBOL)amount (DOUBLE)running_turnover (DOUBLE)
100001TELCO881000881000
100002CITY5150886150
100003CITY2565001142650
100004CITY2572501399900
100005CITY2585001658400
100006COAST802001738600
100007TELCO8790002617600
100008COAST79702625570

(8 rows)

ROWS n PRECEDING

Syntax:

aggregate(x) OVER ([PARTITION BY ...] [ORDER BY ...] ROWS n PRECEDING)

A moving window: this row and the n rows before it (so n + 1 rows). ROWS 4 PRECEDING is a 5-day window. At the start of a series there are fewer rows before, and the window is smaller.

ArgumentTypeMeaning
nwhole number, 0 or morehow many rows before this row; 0 is only this row

Example: 5-day high, 5-day low and 3-day average volume (sample data: sample)

SELECT time, symbol, close,
       max(high) OVER (PARTITION BY symbol ORDER BY time ROWS 4 PRECEDING) AS high_5d,
       min(low) OVER (PARTITION BY symbol ORDER BY time ROWS 4 PRECEDING) AS low_5d,
       round(avg(volume) OVER (PARTITION BY symbol ORDER BY time ROWS 2 PRECEDING)) AS avg_vol_3d
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-01'
ORDER BY time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)high_5d (DOUBLE)low_5d (DOUBLE)avg_vol_3d (DOUBLE)
2026-09-01T00:00:00.000ZCITY502.4507.3501.383000
2026-09-02T00:00:00.000ZCITY496.4507.3494.191675
2026-09-03T00:00:00.000ZCITY497.9507.3494.194783
2026-09-06T00:00:00.000ZCITY499507.3494.192100
2026-09-07T00:00:00.000ZCITY498.7507.3494.197677
2026-09-08T00:00:00.000ZCITY497.5502.8494.175477
2026-09-09T00:00:00.000ZCITY504.6512.4495111290
2026-09-10T00:00:00.000ZCITY504.9512.4495133277

(8 rows)

Example: The short form only goes back: for rows after, write BETWEEN (sample data: sample; shows an error)

SELECT sum(volume) OVER (ORDER BY time ROWS 2 FOLLOWING) FROM ochl_day;

Result:

Error: line 1, column 40: a frame that starts after the current row needs an end: write BETWEEN n FOLLOWING AND ...

ROWS UNBOUNDED PRECEDING

Syntax:

aggregate(x) OVER ([PARTITION BY ...] [ORDER BY ...] ROWS UNBOUNDED PRECEDING)

A running window: from the first row of the part to this row. It is the same as ORDER BY without ROWS, written out.

Example: Highest high so far, and the running number of trading days (sample data: sample)

SELECT time, high,
       max(high) OVER (ORDER BY time ROWS UNBOUNDED PRECEDING) AS high_so_far,
       count(*) OVER (ORDER BY time ROWS UNBOUNDED PRECEDING) AS day_no
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-09-01'
ORDER BY time;

Result:

time (TIMESTAMP)high (DOUBLE)high_so_far (DOUBLE)day_no (LONG)
2026-09-01T00:00:00.000Z172.1172.11
2026-09-02T00:00:00.000Z174.6174.62
2026-09-03T00:00:00.000Z177.3177.33
2026-09-06T00:00:00.000Z183.9183.94
2026-09-07T00:00:00.000Z183.2183.95
2026-09-08T00:00:00.000Z183.5183.96
2026-09-09T00:00:00.000Z189.5189.57
2026-09-10T00:00:00.000Z192.1192.18

(8 rows)

ROWS BETWEEN n PRECEDING AND CURRENT ROW

Syntax:

aggregate(x) OVER ([PARTITION BY ...] [ORDER BY ...] ROWS BETWEEN n PRECEDING AND CURRENT ROW)
aggregate(x) OVER ([PARTITION BY ...] [ORDER BY ...] ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)

The long, standard SQL way to write ROWS n PRECEDING and ROWS UNBOUNDED PRECEDING. Both forms give the same result. With BETWEEN, the end can also be after this row: see ROWS BETWEEN ... FOLLOWING.

Example: Long and short form give the same moving sum (sample data: sample)

SELECT time, volume,
       sum(volume) OVER (ORDER BY time ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS long_form,
       sum(volume) OVER (ORDER BY time ROWS 2 PRECEDING) AS short_form
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)volume (DOUBLE)long_form (DOUBLE)short_form (DOUBLE)
2026-09-06T00:00:00.000Z143620143620143620
2026-09-07T00:00:00.000Z113010256630256630
2026-09-08T00:00:00.000Z60980317610317610
2026-09-09T00:00:00.000Z186330360320360320
2026-09-10T00:00:00.000Z186200433510433510

(5 rows)

Example: The end of the frame cannot be before its start (sample data: sample; shows an error)

SELECT sum(volume) OVER (ORDER BY time ROWS BETWEEN CURRENT ROW AND 2 PRECEDING) FROM ochl_day;

Result:

Error: line 1, column 40: the frame ends before it starts; expected the end bound after the start bound

ROWS BETWEEN ... FOLLOWING

Syntax:

aggregate(x) OVER ([PARTITION BY ...] [ORDER BY ...] ROWS BETWEEN start AND end)

start:  UNBOUNDED PRECEDING | n PRECEDING | CURRENT ROW | n FOLLOWING
end:    n PRECEDING | CURRENT ROW | n FOLLOWING | UNBOUNDED FOLLOWING

A frame that also reads rows after this row. n FOLLOWING is n rows after this row; UNBOUNDED FOLLOWING is up to the last row of the part. ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING is a centered moving window of 5 rows: this row is in the middle.

ArgumentTypeMeaning
nwhole number, 0 or morehow many rows before (PRECEDING) or after (FOLLOWING) this row

Example: Centered 5-day average, volume still to come, and the best close of the next 3 days (sample data: sample)

SELECT time, close,
       round(avg(close) OVER (ORDER BY time ROWS BETWEEN 2 PRECEDING AND 2 FOLLOWING), 2) AS centered_avg,
       sum(volume) OVER (ORDER BY time ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) AS volume_from_here,
       max(close) OVER (ORDER BY time ROWS BETWEEN 1 FOLLOWING AND 3 FOLLOWING) AS next_3_days_max
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-09-01'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)centered_avg (DOUBLE)volume_from_here (DOUBLE)next_3_days_max (DOUBLE)
2026-09-01T00:00:00.000Z367.4369.93910270375.4
2026-09-02T00:00:00.000Z369.2371.3844060379.2
2026-09-03T00:00:00.000Z373.2372.88772910379.2
2026-09-06T00:00:00.000Z375.4374.84690140380.1
2026-09-07T00:00:00.000Z379.2377.02546520380.1
2026-09-08T00:00:00.000Z377.2377.6433510380.1
2026-09-09T00:00:00.000Z380.1378.15372530376.1
2026-09-10T00:00:00.000Z376.1377.8186200NULL

(8 rows)

Example: Each day's share of the symbol's volume over all shown days (the full frame) (sample data: sample)

SELECT time, symbol, volume,
       round(volume * 100.0 / sum(volume) OVER (PARTITION BY symbol ORDER BY time
                                                ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), 1) AS share_of_days_pct
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)volume (DOUBLE)share_of_days_pct (DOUBLE)
2026-09-08T00:00:00.000ZCITY344008.6
2026-09-09T00:00:00.000ZCITY18239045.6
2026-09-10T00:00:00.000ZCITY18304045.8
2026-09-08T00:00:00.000ZCOAST6098014.1
2026-09-09T00:00:00.000ZCOAST18633043
2026-09-10T00:00:00.000ZCOAST18620043
2026-09-08T00:00:00.000ZVOLT13677050.2
2026-09-09T00:00:00.000ZVOLT9441034.6
2026-09-10T00:00:00.000ZVOLT4147015.2

(9 rows)

RANGE BETWEEN

Syntax:

aggregate(x) OVER ([PARTITION BY ...] ORDER BY value RANGE BETWEEN start AND end)
aggregate(x) OVER ([PARTITION BY ...] ORDER BY value RANGE distance PRECEDING)

distance:  number | 'interval text' | INTERVAL 'interval text'

A frame by value, not by row count. RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW reads every row whose time is at most 7 days before this row's time. This is what you want when rows are missing: a week has 5 trading days, and holidays make it fewer, so ROWS 6 PRECEDING is not always "one week".

PartMeaning
n PRECEDING, n FOLLOWINGrows whose ORDER BY value is at most n before or after this row's value
'7 days', INTERVAL '1 hour'a distance of time, for a TIMESTAMP or DATE ORDER BY value. Units: ms, s/seconds, m/minutes, h/hours, d/days, w/weeks
a plain numbermilliseconds for a TIMESTAMP, days for a DATE, the value itself for a number
CURRENT ROWthis row and its peers: the rows with the same ORDER BY value

Example: 7-day window by time against 7 rows: holidays make the difference (sample data: sample)

SELECT time, close,
       count(*) OVER (ORDER BY time RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW) AS rows_in_7_days,
       round(avg(close) OVER (ORDER BY time RANGE BETWEEN '7 days' PRECEDING AND CURRENT ROW), 2) AS avg_7_days,
       count(*) OVER (ORDER BY time ROWS 6 PRECEDING) AS rows_in_7_rows
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)rows_in_7_days (LONG)avg_7_days (DOUBLE)rows_in_7_rows (LONG)
2026-08-23T00:00:00.000Z507.91507.91
2026-08-24T00:00:00.000Z510.32509.12
2026-08-25T00:00:00.000Z513.33510.53
2026-08-26T00:00:00.000Z513.24511.184
2026-08-27T00:00:00.000Z505.95510.125
2026-08-30T00:00:00.000Z503.16508.956
2026-08-31T00:00:00.000Z505.76508.587
2026-09-01T00:00:00.000Z502.46507.277
2026-09-02T00:00:00.000Z496.46504.457
2026-09-03T00:00:00.000Z497.96501.97
2026-09-06T00:00:00.000Z4996500.757
2026-09-07T00:00:00.000Z498.76500.027
2026-09-08T00:00:00.000Z497.56498.657
2026-09-09T00:00:00.000Z504.66499.027
2026-09-10T00:00:00.000Z504.96500.437

(15 rows)

Example: Volume of the last hour, for every minute (sample data: sample)

SELECT time, close,
       sum(volume) OVER (ORDER BY time RANGE BETWEEN INTERVAL '1 hour' PRECEDING AND CURRENT ROW) AS volume_last_hour
FROM ochl_minutes
WHERE symbol = 'COAST' AND time IN '2026-09-10'
ORDER BY time DESC
LIMIT 3;

Result:

time (TIMESTAMP)close (DOUBLE)volume_last_hour (DOUBLE)
2026-09-10T09:14:00.000Z376.149370
2026-09-10T09:13:00.000Z376.249320
2026-09-10T09:12:00.000Z37648630

(3 rows)

Example: Numbers: trades of a similar size, and running totals where ties share one total (sample data: sample)

SELECT transaction_id, quantity,
       count(*) OVER (ORDER BY quantity RANGE BETWEEN 10 PRECEDING AND 10 FOLLOWING) AS similar_trades,
       sum(quantity) OVER (ORDER BY quantity RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_with_peers,
       sum(quantity) OVER (ORDER BY quantity) AS running_row_by_row
FROM trade_log
WHERE symbol = 'COAST'
ORDER BY quantity, transaction_id;

Result:

transaction_id (LONG)quantity (LONG)similar_trades (LONG)running_with_peers (LONG)running_row_by_row (LONG)
1000191041010
1000082047030
1000142047050
1000282047070
100009502170120
100013502170170
1000062002570370
1000262002570570
100023500110701070
1000151000230702070
1000311000230703070

(11 rows)

Example: Months have no fixed length (sample data: sample; shows an error)

SELECT sum(volume) OVER (ORDER BY time RANGE BETWEEN '1 month' PRECEDING AND CURRENT ROW) FROM ochl_day;

Result:

Error: line 1, column 8: sum: '1 month' is not a distance like '5 days', '2 hours' or '30 minutes' (months and years are not supported)

lag

Syntax:

lag(x)
lag(x, k)
lag(x, k) OVER ([PARTITION BY ...] [ORDER BY ...])

The value of x in the row k places before this row in the series. lag(close) is the previous close.

ArgumentTypeMeaning
xany typethe value to take
kwhole number, 1 or morehow many rows back; default 1

Example: Gap up or down: today's open against yesterday's close (sample data: sample)

SELECT time, symbol, open, prev_close, round(open - prev_close, 2) AS gap
FROM (SELECT time, symbol, open, lag(close) OVER (PARTITION BY symbol ORDER BY time) AS prev_close
      FROM ochl_day
      WHERE time >= '2026-09-06') t
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)open (DOUBLE)prev_close (DOUBLE)gap (DOUBLE)
2026-09-06T00:00:00.000ZCITY497.4NULLNULL
2026-09-07T00:00:00.000ZCITY497.3499-1.7
2026-09-08T00:00:00.000ZCITY498.2498.7-0.5
2026-09-09T00:00:00.000ZCITY512497.514.5
2026-09-10T00:00:00.000ZCITY504.6504.60
2026-09-06T00:00:00.000ZCOAST374.6NULLNULL
2026-09-07T00:00:00.000ZCOAST373.7375.4-1.7
2026-09-08T00:00:00.000ZCOAST379.8379.20.6
2026-09-09T00:00:00.000ZCOAST398377.220.8
2026-09-10T00:00:00.000ZCOAST380.1380.10
2026-09-06T00:00:00.000ZVOLT174.2NULLNULL
2026-09-07T00:00:00.000ZVOLT181.4180.11.3
2026-09-08T00:00:00.000ZVOLT178.7176.91.8
2026-09-09T00:00:00.000ZVOLT182182.4-0.4
2026-09-10T00:00:00.000ZVOLT185.6187.3-1.7

(15 rows)

Example: Close of 3 days ago (sample data: sample)

SELECT time, close, lag(close, 3) AS close_3_days_ago
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-01'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)close_3_days_ago (DOUBLE)
2026-09-01T00:00:00.000Z502.4NULL
2026-09-02T00:00:00.000Z496.4NULL
2026-09-03T00:00:00.000Z497.9NULL
2026-09-06T00:00:00.000Z499502.4
2026-09-07T00:00:00.000Z498.7496.4
2026-09-08T00:00:00.000Z497.5497.9
2026-09-09T00:00:00.000Z504.6499
2026-09-10T00:00:00.000Z504.9498.7

(8 rows)

Example: lag cannot be used in WHERE: use a subquery (sample data: sample; shows an error)

SELECT time, close FROM ochl_day WHERE close > lag(close);

Result:

Error: line 1, column 48: 'lag' works over a series of rows and can only be used in a SELECT list

lead

Syntax:

lead(x)
lead(x, k)
lead(x, k) OVER ([PARTITION BY ...] [ORDER BY ...])

The value of x in the row k places after this row. lead(close) is the next day's close. Useful to test a signal: "what happened the day after?"

ArgumentTypeMeaning
xany typethe value to take
kwhole number, 1 or morehow many rows forward; default 1

Example: Next day's close after each day (sample data: sample)

SELECT time, symbol, close,
       lead(close) OVER (PARTITION BY symbol ORDER BY time) AS next_close
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)next_close (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5504.6
2026-09-09T00:00:00.000ZCITY504.6504.9
2026-09-10T00:00:00.000ZCITY504.9NULL
2026-09-08T00:00:00.000ZCOAST377.2380.1
2026-09-09T00:00:00.000ZCOAST380.1376.1
2026-09-10T00:00:00.000ZCOAST376.1NULL
2026-09-08T00:00:00.000ZVOLT182.4187.3
2026-09-09T00:00:00.000ZVOLT187.3191.2
2026-09-10T00:00:00.000ZVOLT191.2NULL

(9 rows)

Example: Minutes between forum posts of a thread (sample data: content-forum)

SELECT id, author, created_at,
       (lead(created_at) OVER (PARTITION BY thread_id ORDER BY created_at) - created_at) / 60000 AS minutes_to_next
FROM posts
ORDER BY thread_id, created_at;

Result:

id (LONG)author (STRING)created_at (TIMESTAMP)minutes_to_next (DOUBLE)
1ram2026-09-01T04:15:00.000Z5
2sita2026-09-01T04:20:00.000Z15
3hari2026-09-01T04:35:00.000Z40
4gita2026-09-01T05:15:00.000Z30
5ram2026-09-01T05:45:00.000Z30
6sita2026-09-01T06:15:00.000ZNULL
7hari2026-09-02T03:15:00.000Z10
8gita2026-09-02T03:25:00.000ZNULL

(8 rows)

cumsum

Syntax:

cumsum(x)
cumsum(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The running total: the sum of x from the first row of the series to this row.

ArgumentTypeMeaning
xany numberthe value to add

Example: Running volume per symbol (sample data: sample)

SELECT time, symbol, volume,
       cumsum(volume) OVER (PARTITION BY symbol ORDER BY time) AS running_volume
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)volume (DOUBLE)running_volume (DOUBLE)
2026-09-08T00:00:00.000ZCITY3440034400
2026-09-09T00:00:00.000ZCITY182390216790
2026-09-10T00:00:00.000ZCITY183040399830
2026-09-08T00:00:00.000ZCOAST6098060980
2026-09-09T00:00:00.000ZCOAST186330247310
2026-09-10T00:00:00.000ZCOAST186200433510
2026-09-08T00:00:00.000ZVOLT136770136770
2026-09-09T00:00:00.000ZVOLT94410231180
2026-09-10T00:00:00.000ZVOLT41470272650

(9 rows)

Example: Running balance of the bank account (sample data: query-app)

SELECT line_id, memo, debit, credit,
       sum(debit) OVER (ORDER BY line_id) AS money_in_so_far,
       round(cumsum(coalesce(debit, 0) - coalesce(credit, 0)) OVER (ORDER BY line_id), 2) AS balance
FROM ledger
WHERE account = 1100
ORDER BY line_id;

Result:

line_id (INT)memo (STRING)debit (DECIMAL(2))credit (DECIMAL(2))money_in_so_far (DECIMAL(2))balance (DOUBLE)
1Owner puts money in the bank500000.00NULL500000.00500000
6Pay the brokerNULL257500.00500000.00242500
8Commission on the CITY buyNULL1030.00500000.00241470
10Take cash from the bankNULL30000.00500000.00211470
13CITY cash dividend6250.00NULL506250.00217720
15Sell 20 CITY at 63012600.00NULL518850.00230320

(6 rows)

Example: New app users so far, per Nepal day (sample data: fn-dates)

SELECT day, signups, cumsum(signups) AS total_users
FROM (SELECT to_date(created_at + 20700000) AS day, count(*) AS signups
      FROM signups
      GROUP BY to_date(created_at + 20700000)) t
ORDER BY day;

Result:

day (DATE)signups (LONG)total_users (LONG)
2026-09-0133
2026-09-0225
2026-09-0338
2026-09-0419
2026-09-06110

(5 rows)

cumprod

Syntax:

cumprod(x)
cumprod(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The running product: all values of x from the first row to this row, multiplied. Traders use it to compound returns: if a share rises 1% and then 2%, it grew by 1.01 × 1.02 = 1.0302. So cumprod(1 + return) is what 1 dollar has grown to.

ArgumentTypeMeaning
xany numberthe value to multiply

Example: Growth of 1 dollar in VOLT, day by day (sample data: sample)

SELECT time, close, ratio, round(cumprod(ratio), 4) AS growth_of_1
FROM (SELECT time, close, close / lag(close) AS ratio
      FROM ochl_day
      WHERE symbol = 'VOLT' AND time >= '2026-09-01') t
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)ratio (DOUBLE)growth_of_1 (DOUBLE)
2026-09-01T00:00:00.000Z169NULLNULL
2026-09-02T00:00:00.000Z171.51.014792899408281.0148
2026-09-03T00:00:00.000Z1761.026239067055391.0414
2026-09-06T00:00:00.000Z180.11.023295454545451.0657
2026-09-07T00:00:00.000Z176.90.982232093281511.0467
2026-09-08T00:00:00.000Z182.41.031091011871111.0793
2026-09-09T00:00:00.000Z187.31.026864035087721.1083
2026-09-10T00:00:00.000Z191.21.020822210357711.1314

(8 rows)

Example: Text cannot be multiplied (sample data: sample; shows an error)

SELECT cumprod(symbol) FROM ochl_day;

Result:

Error: line 1, column 8: cumprod: needs numbers, got 'CITY'

cummax

Syntax:

cummax(x)
cummax(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The running maximum: the biggest value of x from the first row to this row. On prices it is the "highest so far". Works for numbers, text and times.

ArgumentTypeMeaning
xnumber, text or timethe value

Example: Record volume days, and how far the close is below its high so far (sample data: sample)

SELECT time, volume, cummax(volume) AS record_volume,
       CASE WHEN volume = cummax(volume) THEN 'new record' END AS note,
       round(close / cummax(close) * 100 - 100, 2) AS below_high_pct
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-08-23'
ORDER BY time;

Result:

time (TIMESTAMP)volume (DOUBLE)record_volume (DOUBLE)note (STRING)below_high_pct (DOUBLE)
2026-08-23T00:00:00.000Z125050125050new record0
2026-08-24T00:00:00.000Z130800130800new record0
2026-08-25T00:00:00.000Z84980130800NULL0
2026-08-26T00:00:00.000Z138310138310new record-0.02
2026-08-27T00:00:00.000Z122630138310NULL-1.44
2026-08-30T00:00:00.000Z94540138310NULL-1.99
2026-08-31T00:00:00.000Z159240159240new record-1.48
2026-09-01T00:00:00.000Z83000159240NULL-2.12
2026-09-02T00:00:00.000Z100350159240NULL-3.29
2026-09-03T00:00:00.000Z101000159240NULL-3
2026-09-06T00:00:00.000Z74950159240NULL-2.79
2026-09-07T00:00:00.000Z117080159240NULL-2.84
2026-09-08T00:00:00.000Z34400159240NULL-3.08
2026-09-09T00:00:00.000Z182390182390new record-1.69
2026-09-10T00:00:00.000Z183040183040new record-1.64

(15 rows)

Example: The latest login so far, going through the users by id (sample data: query-app)

SELECT id, username, last_login, cummax(last_login) OVER (ORDER BY id) AS latest_so_far
FROM app_users
ORDER BY id;

Result:

id (INT)username (STRING)last_login (TIMESTAMP)latest_so_far (TIMESTAMP)
1ram2026-09-10T14:30:00.000Z2026-09-10T14:30:00.000Z
2sita2026-09-09T02:15:00.000Z2026-09-10T14:30:00.000Z
3hari_99NULL2026-09-10T14:30:00.000Z
4gita2026-09-11T00:45:00.000Z2026-09-11T00:45:00.000Z
5bikash2026-09-11T04:20:00.000Z2026-09-11T04:20:00.000Z
6anita2026-08-30T06:15:00.000Z2026-09-11T04:20:00.000Z
7suman2026-09-10T03:55:00.000Z2026-09-11T04:20:00.000Z
8test_userNULL2026-09-11T04:20:00.000Z

(8 rows)

cummin

Syntax:

cummin(x)
cummin(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The running minimum: the smallest value of x from the first row to this row ("lowest so far"). Works for numbers, text and times.

ArgumentTypeMeaning
xnumber, text or timethe value

Example: Lowest low so far per symbol, and the bounce from it (sample data: sample)

SELECT time, symbol, low, close, low_so_far, round((close - low_so_far) / low_so_far * 100, 2) AS above_low_pct
FROM (SELECT time, symbol, low, close,
             cummin(low) OVER (PARTITION BY symbol ORDER BY time) AS low_so_far
      FROM ochl_day
      WHERE time >= '2026-09-01') t
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)low (DOUBLE)close (DOUBLE)low_so_far (DOUBLE)above_low_pct (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5497.5494.10.69
2026-09-09T00:00:00.000ZCITY504504.6494.12.13
2026-09-10T00:00:00.000ZCITY500.7504.9494.12.19
2026-09-08T00:00:00.000ZCOAST374.7377.2364.13.6
2026-09-09T00:00:00.000ZCOAST379.2380.1364.14.39
2026-09-10T00:00:00.000ZCOAST373.2376.1364.13.3
2026-09-08T00:00:00.000ZVOLT178.2182.4167.39.03
2026-09-09T00:00:00.000ZVOLT181.7187.3167.311.95
2026-09-10T00:00:00.000ZVOLT184.2191.2167.314.29

(9 rows)

pct_change

Syntax:

pct_change(x)
pct_change(x) OVER ([PARTITION BY ...] [ORDER BY ...])

The change from the previous row, in percent: (x - previous) / previous × 100. On daily closes this is the daily return.

ArgumentTypeMeaning
xany numberthe value

Example: Daily return of every symbol (sample data: sample)

SELECT time, symbol, close,
       round(pct_change(close) OVER (PARTITION BY symbol ORDER BY time), 2) AS return_pct
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)return_pct (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5NULL
2026-09-09T00:00:00.000ZCITY504.61.43
2026-09-10T00:00:00.000ZCITY504.90.06
2026-09-08T00:00:00.000ZCOAST377.2NULL
2026-09-09T00:00:00.000ZCOAST380.10.77
2026-09-10T00:00:00.000ZCOAST376.1-1.05
2026-09-08T00:00:00.000ZVOLT182.4NULL
2026-09-09T00:00:00.000ZVOLT187.32.69
2026-09-10T00:00:00.000ZVOLT191.22.08

(9 rows)

Example: Growth of forum posts week by week (sample data: fn-dates)

SELECT week, posts, round(pct_change(posts), 1) AS growth_pct
FROM (SELECT date_trunc('week', created_at) AS week, count(*) AS posts
      FROM forum_posts
      GROUP BY date_trunc('week', created_at)) t
ORDER BY week;

Result:

week (TIMESTAMP)posts (LONG)growth_pct (DOUBLE)
2026-08-16T00:00:00.000Z3NULL
2026-08-23T00:00:00.000Z433.3
2026-08-30T00:00:00.000Z3-25
2026-09-06T00:00:00.000Z433.3

(4 rows)

row_number

Syntax:

row_number()
row_number() OVER ([PARTITION BY ...] [ORDER BY ...])

Numbers the rows of each part: 1, 2, 3, ... Equal values still get different numbers.

Example: Top 2 trades by amount per symbol (sample data: sample)

SELECT symbol, pos, transaction_id, quantity, rate, amount
FROM (SELECT symbol, transaction_id, quantity, rate, amount,
             row_number() OVER (PARTITION BY symbol ORDER BY amount DESC, transaction_id) AS pos
      FROM trade_log) t
WHERE pos <= 2
ORDER BY symbol, pos;

Result:

symbol (SYMBOL)pos (LONG)transaction_id (LONG)quantity (LONG)rate (DOUBLE)amount (DOUBLE)
CITY1100005500517258500
CITY2100018500516258000
COAST11000311000402402000
COAST21000151000398398000
TELCO11000011000881881000
TELCO21000071000879879000
VOLT110001220018436800
VOLT210002010018718700

(8 rows)

Example: Page numbers for a list of forum posts: 3 posts per page (sample data: content-forum)

SELECT id, author, floor((row_number() OVER (ORDER BY created_at) - 1) / 3) + 1 AS page
FROM posts
ORDER BY created_at;

Result:

id (LONG)author (STRING)page (DOUBLE)
1ram1
2sita1
3hari1
4gita2
5ram2
6sita2
7hari3
8gita3

(8 rows)

rank

Syntax:

rank()
rank() OVER ([PARTITION BY ...] ORDER BY ...)

The place of the row in the window's order. Equal values get the same rank, and the next rank skips: 1, 1, 3.

Example: Rank the day's trades of COAST by quantity (ties share a rank) (sample data: sample)

SELECT transaction_id, buyer, quantity,
       rank() OVER (ORDER BY quantity DESC) AS rank,
       dense_rank() OVER (ORDER BY quantity DESC) AS dense_rank,
       row_number() OVER (ORDER BY quantity DESC, transaction_id) AS row_number
FROM trade_log
WHERE date IN '2026-09-10' AND symbol = 'COAST'
ORDER BY quantity DESC, transaction_id;

Result:

transaction_id (LONG)buyer (INT)quantity (LONG)rank (LONG)dense_rank (LONG)row_number (LONG)
100015491000111
100031451000112
10002358500323
1000261200434
1000093450545
1000135850546
1000144520757
1000284920758
1000195810969

(9 rows)

Example: Biggest gainer of each day (sample data: sample)

SELECT time, symbol, change_pct,
       rank() OVER (PARTITION BY time ORDER BY change_pct DESC) AS place
FROM (SELECT time, symbol, round((close - open) / open * 100, 2) AS change_pct
      FROM ochl_day
      WHERE time >= '2026-09-08') t
ORDER BY time, place;

Result:

time (TIMESTAMP)symbol (SYMBOL)change_pct (DOUBLE)place (LONG)
2026-09-08T00:00:00.000ZVOLT2.071
2026-09-08T00:00:00.000ZCITY-0.142
2026-09-08T00:00:00.000ZCOAST-0.683
2026-09-09T00:00:00.000ZVOLT2.911
2026-09-09T00:00:00.000ZCITY-1.452
2026-09-09T00:00:00.000ZCOAST-4.53
2026-09-10T00:00:00.000ZVOLT3.021
2026-09-10T00:00:00.000ZCITY0.062
2026-09-10T00:00:00.000ZCOAST-1.053

(9 rows)

dense_rank

Syntax:

dense_rank()
dense_rank() OVER ([PARTITION BY ...] ORDER BY ...)

Like rank, but without gaps: equal values get the same rank, and the next rank is one more: 1, 1, 2.

Example: Price levels of VOLT's trades (sample data: sample)

SELECT transaction_id, rate,
       dense_rank() OVER (ORDER BY rate DESC) AS price_level,
       rank() OVER (ORDER BY rate DESC) AS rank
FROM trade_log
WHERE symbol = 'VOLT'
ORDER BY rate DESC, transaction_id;

Result:

transaction_id (LONG)rate (DOUBLE)price_level (LONG)rank (LONG)
10002018711
10001618522
10001118433
10001218433

(4 rows)

percent_rank

Syntax:

percent_rank()
percent_rank() OVER ([PARTITION BY ...] ORDER BY ...)

The place of the row as a part from 0 to 1: (rank - 1) / (rows - 1). The first row is 0, the last is 1. 0.9 means "90% of the other rows come before this one". It is the percentile rank.

Example: Where does each COAST trade size stand? percent_rank, cume_dist and rank (sample data: sample)

SELECT transaction_id, quantity,
       rank() OVER (ORDER BY quantity) AS rank,
       round(percent_rank() OVER (ORDER BY quantity), 2) AS percent_rank,
       round(cume_dist() OVER (ORDER BY quantity), 2) AS cume_dist
FROM trade_log
WHERE symbol = 'COAST'
ORDER BY quantity, transaction_id;

Result:

transaction_id (LONG)quantity (LONG)rank (LONG)percent_rank (DOUBLE)cume_dist (DOUBLE)
10001910100.09
1000082020.10.36
1000142020.10.36
1000282020.10.36
1000095050.40.55
1000135050.40.55
10000620070.60.73
10002620070.60.73
10002350090.80.82
1000151000100.91
1000311000100.91

(11 rows)

Example: How high are the last closes among all September closes of the same symbol? (100 = the highest) (sample data: sample)

SELECT time, symbol, close, percentile_rank
FROM (SELECT time, symbol, close,
             round(percent_rank() OVER (PARTITION BY symbol ORDER BY close) * 100) AS percentile_rank
      FROM ochl_day
      WHERE time >= '2026-09-01') t
WHERE time >= '2026-09-09'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)percentile_rank (DOUBLE)
2026-09-09T00:00:00.000ZCITY504.686
2026-09-10T00:00:00.000ZCITY504.9100
2026-09-09T00:00:00.000ZCOAST380.1100
2026-09-10T00:00:00.000ZCOAST376.157
2026-09-09T00:00:00.000ZVOLT187.386
2026-09-10T00:00:00.000ZVOLT191.2100

(6 rows)

cume_dist

Syntax:

cume_dist()
cume_dist() OVER ([PARTITION BY ...] ORDER BY ...)

The cumulative distribution: the part of the rows (from 0 to 1) whose value is at or before this row's value. 0.75 means "75% of the rows have this value or a smaller one".

Example: What part of the app users signed up by each day? (sample data: fn-dates)

SELECT id, created_at, round(cume_dist() OVER (ORDER BY created_at) * 100) AS pct_of_users_so_far
FROM signups
ORDER BY created_at;

Result:

id (INT)created_at (TIMESTAMP)pct_of_users_so_far (DOUBLE)
12026-09-01T03:30:00.000Z10
22026-09-01T07:55:00.000Z20
32026-09-01T16:20:00.000Z30
42026-09-01T20:45:00.000Z40
52026-09-02T05:15:00.000Z50
62026-09-03T02:35:00.000Z60
72026-09-03T14:00:00.000Z70
82026-09-03T18:05:00.000Z80
92026-09-04T04:25:00.000Z90
102026-09-06T06:15:00.000Z100

(10 rows)

ntile

Syntax:

ntile(n)
ntile(n) OVER ([PARTITION BY ...] ORDER BY ...)

Cuts the rows of each part into n groups of (almost) the same size, in window order, and gives the group number: 1 to n. ntile(4) gives quartiles, ntile(10) deciles.

ArgumentTypeMeaning
nwhole number, 1 or morehow many groups

Example: Quartiles of COAST's September closes (1 = highest) (sample data: sample)

SELECT time, close, ntile(4) OVER (ORDER BY close DESC) AS quartile
FROM ochl_day
WHERE symbol = 'COAST' AND time >= '2026-09-01'
ORDER BY close DESC;

Result:

time (TIMESTAMP)close (DOUBLE)quartile (LONG)
2026-09-09T00:00:00.000Z380.11
2026-09-07T00:00:00.000Z379.21
2026-09-08T00:00:00.000Z377.22
2026-09-10T00:00:00.000Z376.12
2026-09-06T00:00:00.000Z375.43
2026-09-03T00:00:00.000Z373.23
2026-09-02T00:00:00.000Z369.24
2026-09-01T00:00:00.000Z367.44

(8 rows)

Example: Three groups of trades by size: 11 rows are 4 + 4 + 3 (sample data: sample)

SELECT transaction_id, quantity, ntile(3) OVER (ORDER BY quantity, transaction_id) AS size_group
FROM trade_log
WHERE symbol = 'COAST'
ORDER BY quantity, transaction_id;

Result:

transaction_id (LONG)quantity (LONG)size_group (LONG)
100019101
100008201
100014201
100028201
100009502
100013502
1000062002
1000262002
1000235003
10001510003
10003110003

(11 rows)

Example: The number of groups must be 1 or more (sample data: sample; shows an error)

SELECT ntile(0) OVER (ORDER BY close) FROM ochl_day;

Result:

Error: line 1, column 8: ntile: the number of buckets must be a whole number (1 or more)

first_value

Syntax:

first_value(x)
first_value(x) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE BETWEEN ... AND ...])

The value of x in the first row of the frame. With OVER (PARTITION BY symbol ORDER BY time) it is the first price of the symbol, on every row.

ArgumentTypeMeaning
xany typethe value to take

Example: Change since the first day of the series, per symbol (sample data: sample)

SELECT time, symbol, close, start_close, round((close - start_close) / start_close * 100, 2) AS change_pct
FROM (SELECT time, symbol, close,
             first_value(close) OVER (PARTITION BY symbol ORDER BY time) AS start_close
      FROM ochl_day
      WHERE time >= '2026-09-01') t
WHERE time >= '2026-09-09'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)start_close (DOUBLE)change_pct (DOUBLE)
2026-09-09T00:00:00.000ZCITY504.6502.40.44
2026-09-10T00:00:00.000ZCITY504.9502.40.5
2026-09-09T00:00:00.000ZCOAST380.1367.43.46
2026-09-10T00:00:00.000ZCOAST376.1367.42.37
2026-09-09T00:00:00.000ZVOLT187.316910.83
2026-09-10T00:00:00.000ZVOLT191.216913.14

(6 rows)

Example: Open of each one-minute candle against the day's first open (sample data: sample)

SELECT time, open, first_value(open) AS day_open, round(open - first_value(open), 1) AS from_day_open
FROM ochl_minutes
WHERE symbol = 'CITY' AND time IN '2026-09-10'
ORDER BY time DESC
LIMIT 3;

Result:

time (TIMESTAMP)open (DOUBLE)day_open (DOUBLE)from_day_open (DOUBLE)
2026-09-10T09:14:00.000Z505.1504.60.5
2026-09-10T09:13:00.000Z504.8504.60.2
2026-09-10T09:12:00.000Z504.5504.6-0.1

(3 rows)

last_value

Syntax:

last_value(x)
last_value(x) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE BETWEEN ... AND ...])

The value of x in the last row of the frame.

ArgumentTypeMeaning
xany typethe value to take

Example: The trap: without a frame, last_value is this row (sample data: sample)

SELECT time, close,
       last_value(close) OVER (ORDER BY time) AS this_row,
       last_value(close) OVER (ORDER BY time ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS latest_close,
       last_value(close) AS also_latest
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)this_row (DOUBLE)latest_close (DOUBLE)also_latest (DOUBLE)
2026-09-06T00:00:00.000Z499499504.9504.9
2026-09-07T00:00:00.000Z498.7498.7504.9504.9
2026-09-08T00:00:00.000Z497.5497.5504.9504.9
2026-09-09T00:00:00.000Z504.6504.6504.9504.9
2026-09-10T00:00:00.000Z504.9504.9504.9504.9

(5 rows)

Example: How far each day's close is from the latest close, per symbol (sample data: sample)

SELECT time, symbol, close, latest, round(latest - close, 1) AS to_latest
FROM (SELECT time, symbol, close,
             last_value(close) OVER (PARTITION BY symbol ORDER BY time
                                     ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS latest
      FROM ochl_day
      WHERE time >= '2026-09-08') t
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)latest (DOUBLE)to_latest (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5504.97.4
2026-09-09T00:00:00.000ZCITY504.6504.90.3
2026-09-10T00:00:00.000ZCITY504.9504.90
2026-09-08T00:00:00.000ZCOAST377.2376.1-1.1
2026-09-09T00:00:00.000ZCOAST380.1376.1-4
2026-09-10T00:00:00.000ZCOAST376.1376.10
2026-09-08T00:00:00.000ZVOLT182.4191.28.8
2026-09-09T00:00:00.000ZVOLT187.3191.23.9
2026-09-10T00:00:00.000ZVOLT191.2191.20

(9 rows)

nth_value

Syntax:

nth_value(x, n)
nth_value(x, n) OVER ([PARTITION BY ...] [ORDER BY ...] [ROWS | RANGE BETWEEN ... AND ...])

The value of x in row number n of the frame (1 is the first row).

ArgumentTypeMeaning
xany typethe value to take
nwhole number, 1 or morewhich row of the frame

Example: The second day's close, from the second day on (sample data: sample)

SELECT time, close, nth_value(close, 2) OVER (ORDER BY time) AS second_close
FROM ochl_day
WHERE symbol = 'CITY' AND time >= '2026-09-06'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)second_close (DOUBLE)
2026-09-06T00:00:00.000Z499NULL
2026-09-07T00:00:00.000Z498.7498.7
2026-09-08T00:00:00.000Z497.5498.7
2026-09-09T00:00:00.000Z504.6498.7
2026-09-10T00:00:00.000Z504.9498.7

(5 rows)

Example: The second-biggest trade of each symbol, on every row (sample data: sample)

SELECT DISTINCT symbol, second_biggest
FROM (SELECT symbol,
             nth_value(amount, 2) OVER (PARTITION BY symbol ORDER BY amount DESC
                                        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS second_biggest
      FROM trade_log) t
ORDER BY symbol;

Result:

symbol (SYMBOL)second_biggest (DOUBLE)
CITY258000
COAST398000
TELCO879000
VOLT18700

(4 rows)

Example: Row numbers start at 1 (sample data: sample; shows an error)

SELECT nth_value(close, 0) OVER (ORDER BY time) FROM ochl_day;

Result:

Error: line 1, column 8: nth_value: the row number must be a whole number (1 or more)

Aggregates with OVER

Syntax:

count(*) OVER (...)    count(x) OVER (...)    sum(x) OVER (...)    avg(x) OVER (...)
min(x) OVER (...)      max(x) OVER (...)      first(x) OVER (...)  last(x) OVER (...)
median(x) OVER (...)   percentile(x, p) OVER (...)   stddev(x) OVER (...)   vwap(price, volume) OVER (...)

Every aggregate (aggregate functions, statistics) can be a window function, also the newer ones like count_if, corr or sharpe_ratio. It then gives a value for every row, computed over the row's window.

WindowWhat you get
OVER () or OVER (PARTITION BY ...)the total of the whole part on every row (share of total, difference from the average)
OVER (... ORDER BY ...)a running value: running total, highest so far
OVER (... ORDER BY ... ROWS n PRECEDING)a moving value: 5-day sum, 20-day high, moving VWAP

Example: Distance from the symbol's average close (sample data: sample)

SELECT time, symbol, close,
       round(avg(close) OVER (PARTITION BY symbol), 2) AS avg_close,
       round(close - avg(close) OVER (PARTITION BY symbol), 2) AS diff
FROM ochl_day
WHERE time >= '2026-09-08'
ORDER BY symbol, time;

Result:

time (TIMESTAMP)symbol (SYMBOL)close (DOUBLE)avg_close (DOUBLE)diff (DOUBLE)
2026-09-08T00:00:00.000ZCITY497.5502.33-4.83
2026-09-09T00:00:00.000ZCITY504.6502.332.27
2026-09-10T00:00:00.000ZCITY504.9502.332.57
2026-09-08T00:00:00.000ZCOAST377.2377.8-0.6
2026-09-09T00:00:00.000ZCOAST380.1377.82.3
2026-09-10T00:00:00.000ZCOAST376.1377.8-1.7
2026-09-08T00:00:00.000ZVOLT182.4186.97-4.57
2026-09-09T00:00:00.000ZVOLT187.3186.970.33
2026-09-10T00:00:00.000ZVOLT191.2186.974.23

(9 rows)

Example: Moving 3-day VWAP (sample data: sample)

SELECT time, close, volume,
       round(vwap(close, volume) OVER (ORDER BY time ROWS 2 PRECEDING), 2) AS vwap_3d
FROM ochl_day
WHERE symbol = 'VOLT' AND time >= '2026-09-03'
ORDER BY time;

Result:

time (TIMESTAMP)close (DOUBLE)volume (DOUBLE)vwap_3d (DOUBLE)
2026-09-03T00:00:00.000Z176145930176
2026-09-06T00:00:00.000Z180.178550177.43
2026-09-07T00:00:00.000Z176.9135560177.23
2026-09-08T00:00:00.000Z182.4136770179.76
2026-09-09T00:00:00.000Z187.394410181.63
2026-09-10T00:00:00.000Z191.241470185.44

(6 rows)

A window function can sit next to GROUP BY. It runs over the grouped rows, after the aggregates, as in PostgreSQL.

Example: A window over groups: rank symbols by volume (sample data: sample)

SELECT symbol, sum(volume) AS volume,
       rank() OVER (ORDER BY sum(volume) DESC) AS place
FROM ochl_day
GROUP BY symbol
ORDER BY place;

Result:

symbol (SYMBOL)volume (DOUBLE)place (LONG)
CITY19868501
VOLT18000302
COAST16745103

(3 rows)

Example: The same idea with GROUP BY inside, window outside (sample data: sample)

SELECT day, turnover, sum(turnover) OVER (ORDER BY day) AS running_turnover
FROM (SELECT date AS day, sum(amount) AS turnover
      FROM trade_log
      GROUP BY date) t
ORDER BY day;

Result:

day (TIMESTAMP)turnover (DOUBLE)running_turnover (DOUBLE)
2026-09-09T00:00:00.000Z26255702625570
2026-09-10T00:00:00.000Z23613804986950

(2 rows)