# Window functions
Design note for provenance support of SQL window functions
(`f(…) OVER (PARTITION BY … ORDER BY … [frame])`).
## Status today
Tiers 1 and 2 are implemented: an aggregate used as a window function over a
frame determined by values, and `rank()`, `dense_rank()`, `row_number()`, become
`agg_token`s over the rows of their frames (`replace_window_aggregations`, test
`window_aggregates`, user documentation in `user/aggregation.rst`).
Any other window function runs as before, with a warning: each output row
carries the provenance of its input row, and the windowed value is an opaque
scalar. That is right for the row, and says nothing of the value: `lag(x)` is a
plain value, although which row it reads depends on which rows are present.
## Why it matters
Measured on three public corpora of queries written by people (Stack Exchange
Data Explorer, 11,226 distinct queries; SQLShare, 8,670; the statements of the
PostgreSQL-tagged posts of DBA Stack Exchange, 16,251), window functions occur
in 11.9 %, 2.1 % and 7.1 % of the queries. No other construct that ProvSQL does
not track comes close. Among the queries that use one:
| | SEDE | SQLShare | DBA.SE |
|---|---|---|---|
| ranking (`row_number`, `rank`, `dense_rank`) | 93.0 % | 94.0 % | 44.3 % |
| offset (`lag`, `lead`, `first_value`, `last_value`) | 1.1 % | – | 29.4 % |
| running aggregate (ORDER BY, prefix frame) | 2.2 % | – | 20.0 % |
| whole-partition aggregate (no ORDER BY) | 2.8 % | 13.2 % | 18.5 % |
| sliding `ROWS` aggregate | 0.1 % | – | 2.9 % |
| `ntile`, `percent_rank`, `cume_dist` | 1.8 % | – | 1.9 % |
| value compared with a constant afterwards (`rn = 1`, `rk <= 10`) | 12.3 % | 19.8 % | 23.7 % |
| value only output by the statement | 78.3 % | 41.2 % | 31.8 % |
| value aggregated afterwards | 1.4 % | – | 11.1 % |
| value used as a grouping key afterwards | 1.9 % | – | 8.0 % |
Two uses dominate: numbering or ranking the rows of a result, and the
"top-k per group" idiom, a rank computed in a subquery and compared with a
constant outside.
## Plain semantics
We follow the operator of Lindner, Naumann and Lerner, *Window Function
Optimization: Co-Evaluation and Other Techniques*, PVLDB 19(11), 2026
([PDF](https://www.vldb.org/pvldb/vol19/p3525-lindner.pdf)), which to our
knowledge is the first formal definition of window functions. The operator
⊞P,O,w,f(r) has four parameters, the partition attributes P, the
order attributes O (with direction and NULL ordering), a frame function w and an
aggregate or ranking function f, and is defined over sequences, in six phases:
1. partition r by P (syntactic equality: NULLs are equal);
2. sort each partition by O into a sequence Sj;
3. for each position i, the frame function gives a sequence of indexes into
Sj, hence the frame contents Wi,j;
4. f maps that sequence to a value;
5. emit the tuple extended with the value;
6. collect the partitions back into a bag.
When O does not determine the order, several sequences are valid, and the result
of position-dependent functions (`row_number`, `lag`, `ROWS` frames) is not
determined. Their Equivalence (4) is the base case below: for a standard
aggregate f and a window with no partitioning, ⊞∅,…,f(r) ≡ r ×
γf(r), a window aggregate being the join of each row with the grouped
query.
## Annotated semantics
The row keeps its annotation: a window function neither removes nor merges
rows. The value becomes an aggregate token, as for `GROUP BY`: the function f
together with the annotated sequence of the occurrences it may read, its value
in a world being f applied to the occurrences present in that world.
One change to the plain operator is needed. Its frame function returns
*positions* in the sorted partition of the instance, and positions are not
stable under uncertainty: "the previous row" is the previous row *that is
present*. The frame of the annotated semantics is therefore defined from the
values of the ordering attributes, which are certain, and f reads the
subsequence of present occurrences:
- whole partition, the default frame (`RANGE UNBOUNDED PRECEDING` to the current
row and its peers), explicit `RANGE` and `GROUPS` frames: the frame is a set of
rows determined by values, f is the ordinary aggregate. Positional and
value-based definitions coincide;
- `rank()`: 1 + `count` over the rows strictly before the current one;
`dense_rank()`: 1 + the number of distinct ordering values strictly before;
- `lag(x)`, `lead(x)`, `first_value(x)`, `last_value(x)`: "first present element"
of a value-determined sequence (the strict predecessors in reverse order, for
`lag`), which is the order-dependent `choose` aggregate;
- `ROWS BETWEEN k PRECEDING AND CURRENT ROW`: f is "aggregate of the last k + 1
elements" of the prefix; `lag(x, k)`, `nth_value`: "k-th element from the end".
Expressible, but these f are not functions the evaluators know;
- `ntile`, `percent_rank`, `cume_dist`: arithmetic over two counts (a position
and the size of the partition).
Two conventions need care.
- **The current row is present.** A frame that contains the current row is only
ever read in worlds where that row is present. In the Boolean semiring, and in
absorptive semirings, the worlds without it vanish when the value's provenance
is multiplied by the row's annotation, so nothing special is needed; in
general the possible-world sum has to be restricted to the worlds containing
the row ("anchored" token). For `count` and `sum` this can be obtained without
a new notion, as the current row's contribution plus a token over the *other*
rows of the frame.
- **The empty frame has a value.** A frame that excludes the current row (strict
predecessors, for `rank` and `lag`) may be empty while the row exists: `count`
is then 0, the others NULL. This is the convention of scalar aggregation (the
scalar flag of `provenance_aggregate`), not that of grouped aggregation, where
an empty group is no row and `provenance_aggregate` returns `gate_zero()`.
## Plan
### Tier 1: aggregates over a whole partition (done)
`f(x) OVER (PARTITION BY g)`, with f one of the supported aggregates, `FILTER`
included. Exact, and no more expensive than `GROUP BY`.
Lowering, mirroring `make_aggregation_expression`: the `WindowFunc` is replaced
by
```
provenance_aggregate(fn, type,
f(x) OVER w, -- displayed value
array_agg(provenance_semimod(x, k)) OVER w, -- same window
is_scalar)
```
where k is the row's provenance and the second window call shares the `winref`
of the first. `provenance_aggregate` is an ordinary function of the row, which
PostgreSQL evaluates above the `WindowAgg` node. The special cases of
`make_aggregation_expression` (`count(*)`, `count(e)`, `FILTER`, NULL-keeping
aggregates, the ORDER BY of the aggregate) carry over. Gates are
content-addressed, so the rows of a partition share one aggregate gate.
By δ-absorption this is the join of each row with the grouped query, the
equivalence quoted above.
As implemented, `is_scalar` is false when every frame contains its current row,
so that a whole-partition window gets the very gate of the `GROUP BY` (checked
in the test), and true when the frame may exclude it. For the latter,
`provenance_aggregate` over no token now builds an `agg` gate without children
rather than 𝟘, which also fixed `HAVING count(*) < 1` over no row (it failed with
"This semiring does not support value gates"). Windows over the aggregates of a
`GROUP BY` (`sum(sum(x)) OVER ()`) summed the `agg_token` datums as integers;
`cast_agg_token_mutator` now casts the arguments of a `WindowFunc`, and they
stay untracked.
### Tier 2: ranks and running aggregates (done)
- `sum`, `count`, `min`, `max`, `avg` with `ORDER BY` and the default frame, or an
explicit value-based frame: the lowering of tier 1 applies unchanged, since
PostgreSQL's frame is then determined by values. Done, with
`window_frame_by_values` deciding: no `ORDER BY`, `RANGE`, `GROUPS` without
offsets, `ROWS` over the whole partition, any `EXCLUDE`.
- `rank()`: `1 + count(*) OVER (PARTITION BY … ORDER BY … RANGE BETWEEN UNBOUNDED
PRECEDING AND CURRENT ROW EXCLUDE GROUP)`, lowered as above with the scalar
convention, the `+ 1` being arithmetic on an `agg_token` (an `arith` gate).
Done (`make_rank_window`, `make_rank_expression`); PostgreSQL 11 and later,
for `EXCLUDE`. Counting the current row instead (`EXCLUDE TIES`, no `+ 1`)
was tried and dropped: the count then reads the row's own token, which also
multiplies the comparison, and the correlation sent the comparison to
knowledge compilation (minutes for 50 rows).
- `row_number()`: equal to `rank()` when the ordering is total on the partition,
which can be checked on the instance (a sub-instance of a totally ordered
instance is totally ordered). With ties its value is unspecified by SQL
itself: it is then read as `rank()`, with a warning. Done: the rank is
wrapped in `row_number_as_rank`, which warns once per statement when
PostgreSQL's row number differs from it.
- `dense_rank()`: needs the number of distinct ordering values before the
current row. PostgreSQL has no `DISTINCT` in window aggregates, so this goes
through a subquery that merges peers first (one token per ordering value, the
⊕ of its rows), as `AGG(DISTINCT)` does. Done without the subquery: two
`array_agg` over the frame of the rows strictly before collect the ordering
values (a row value, for several keys) and the tokens, and
`window_distinct_tokens` groups them into one `semimod(1, ⊕ tokens)` per
value, under a scalar `COUNT` gate (`make_dense_rank_expression`).
**The top-k idiom.** A rank compared with a constant in an enclosing query,
```sql
SELECT … FROM (SELECT …, rank() OVER (PARTITION BY g ORDER BY s DESC) AS rk FROM t) u
WHERE rk <= 3
```
is a selection on an aggregate column of a subquery, which
`migrate_probabilistic_quals` already routes to a comparison gate. The `+ 1` is
not folded by `normalize_agg_comparison`, which only sees the subquery's
column; the comparison evaluators fold it (`matchAggCmp` peels constant
arithmetic off the aggregate), and a top-3 over 300 rows takes half a second.
The row's
annotation becomes α ⊗ ⟦count of present rows before it < 3⟧, a `COUNT`
comparison: the existing evaluators (the Poisson-binomial pre-pass for
probabilities) apply. This is also the exact annotation of a `LIMIT k`, and
`ORDER BY … LIMIT k` is now lowered to it, at every level of a statement
(`lower_limit_to_rank`, test `limit_rank`, user documentation in
`user/querying.rst`): the query becomes the subquery of a filter on
`row_number()` (or `rank()` for `FETCH … WITH TIES`), and returns every row
that may be kept, annotated with that condition. `LIMIT plain(k)` keeps the
truncation of the actual result, each kept row carrying its provenance in the
full result. Left as truncations: a `LIMIT` without `ORDER BY`, over an
aggregation, `DISTINCT` or set operation, `OFFSET` with `WITH TIES`, and every
`LIMIT` before PostgreSQL 11. `OFFSET m LIMIT k` compares the same rank twice;
the COUNT evaluator resolves the two comparisons together, as a range.
### Tier 3: offset functions
`lag(x)`, `lead(x)`, `first_value(x)`, `last_value(x)` as ordered `choose`
aggregates over a value-determined frame that excludes the current row.
Comparisons on `choose` are already evaluated; ties in the ordering make the
value unspecified, as for `row_number`.
### Not planned
Sliding `ROWS` frames, `lag` / `lead` with an offset, `nth_value`, `ntile`,
`percent_rank`, `cume_dist`; window functions over the result of a `GROUP BY` of
the same query (`avg(sum(x)) OVER (…)`), which aggregate over aggregate values.
They keep the present behavior, with the warning.
## What stays out of reach
A window value is an `agg_token`, with the restrictions that come with it: it
can be output, used in arithmetic, and compared; it cannot be a grouping key, a
join key, or the input of another aggregate. The "gaps and islands" idioms
(flag a change with `lag`, number the runs with a running sum of the flags,
group by that number) are of that last kind: about a quarter of the uses of
`lag` and `lead` in the DBA.SE corpus aggregate the value afterwards.
One restriction had to be lifted: `ORDER BY` on an `agg_token` is refused, and
`ORDER BY rn` is the most common thing done with a row number. Sorting on the
displayed value is presentation, like `ORDER BY` itself: a window value is now
sorted that way (`replace_window_aggregations` moves the sort key to a junk copy
of the original window call). The aggregates of a `GROUP BY` are still refused,
and could be treated the same way.
## Cost
- One aggregate gate per row for an ordered frame, with as many children as the
frame has rows: the circuit of a running aggregate is quadratic in the size of
the partition, and so is the work of `array_agg` over a growing frame. Whole
partitions share one gate and are linear.
- For the monoid aggregates (`sum`, `count`, `min`, `max`), a prefix token could
be built from the previous one plus one term, giving a linear circuit; this
needs the evaluators to accept an aggregate gate among the children of an
aggregate gate. For `lag`, a chain of `case` gates would do the same.
- The per-row cost of `provenance_semimod` (a PL/pgSQL function with several
round trips to the circuit store), which already dominates aggregate queries,
applies here as well.
A first version can take the quadratic form, with the documentation saying so.
## Where in the code
- `process_query`: the warning on `q->hasWindowFuncs` becomes a classification
of the `WindowFunc` nodes of the target list (tiers above, else the warning).
- `replace_aggregations_by_provenance_aggregate` and its mutator only look at
`Aggref` nodes; a sibling for `WindowFunc` builds the call above, reusing
`make_aggregation_expression`'s handling of the argument. The window clause
(`q->windowClause`) is unchanged for tier 1 and the running aggregates; `rank`
adds a window definition with `EXCLUDE GROUP`.
- `insert_agg_token_casts` already knows `WindowFunc` as a consumer of
`agg_token` values (it casts them back); the new values are producers.
- Several rewrites decline queries with `hasWindowFuncs` (sublink
decorrelation, outer-join lowering, the reachability recognizer); that stays.
- Evaluators: the anchored and empty-frame conventions above; nothing else for
tiers 1 and 2.
## Testing
- Displayed values against the same query over an untracked copy, as in
`agg_distinct_expr`.
- Probabilities against brute-force enumeration of the worlds of a small
partition: `rank() <= k`, running `sum` compared with a constant, the first row
of a partition (empty frame), ties.
- Gate sharing for whole partitions; circuit size for running aggregates.
- The refusal or warning paths: `row_number` with ties, `ROWS` frames.
## Open questions
1. Anchoring in non-absorptive semirings: decompose into "current row plus the
others" where possible (`count`, `sum`), and otherwise document the value as
meant for Boolean and absorptive evaluation, or add an explicit flag on the
aggregate gate?
2. Linear-size circuits for running aggregates: worth the change in the
evaluators from the start, or only once the quadratic form has shown its
limits? For a top-k (`LIMIT k`, `rank() <= k`), a circuit of `O(n·k)` gates,
"exactly j of the first i rows are present" for `j < k`, would replace the
quadratic prefix counts. It is deterministic, and decomposable only when the
annotations of the rows share no input (a top-k directly over a
tuple-independent table); after a join, it is a polynomial circuit whose
probability still needs knowledge compilation. In absorptive semirings
where ⊗ distributes over ⊖, the same size is reached in the semiring itself,
through the sums S_C of the worlds of size C and their include/exclude
recurrence (`SC_recurrence`, `atMost_eq_S_monus_S` in the Lean library).