ClickHouse Computes Window Frames Incrementally, One Row at a Time
ClickHouse's window transform keeps the partition sorted, advances a moving frame bound by bound, and updates the aggregate state incrementally instead of recomputing it per row.
TL;DR: A window function is an aggregate over a moving range of rows. The naive implementation recomputes the aggregate for every row, which is quadratic. ClickHouse sorts the input by
PARTITION BYthenORDER BY, keeps three nested bounds — partition, peer group, frame — and for each row advances those bounds by a delta, adding only the rows that entered the frame. When the frame does not move at all, the previous result is copied forward instead of being finalised again.
SELECT
team,
player,
salary,
avg(salary) OVER (PARTITION BY team ORDER BY salary ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) AS rolling
FROM salaries;The temptation is to implement this the obvious way: for each row, gather
the rows in its frame and aggregate them. That is O(n · frame) per
partition, and with the default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) it is O(n²). For a million-row partition that is 10^12 row
visits.
The trick that makes it linear is to notice that consecutive rows have almost the same frame. The frame for row i+1 is the frame for row i plus a few rows at the end and minus a few at the start. If you keep the aggregate’s state alive between rows, you only have to feed it the difference.
That is the entire design of ClickHouse’s window transform, and the rest of this article is about how it manages the fact that the frame’s boundaries are not known until you have seen the data.
after the sort (PARTITION BY team, ORDER BY salary)
team A team B
+----------------------+ +----------------------+
| 10 20 20 30 40 | | 50 50 60 |
+----------------------+ +----------------------+
^partition start ^partition start
| |
peer group: [20 20] peer group: [50 50]
frame (ROWS 2 PRECEDING): frame (RANGE UNBOUNDED PRECEDING):
[10 20 20] [50]
[20 20 20] [50 50]
[20 20 30] [50 50 60] <- the start never moves
[20 30 40] ...
for each row: advance the frame, feed the aggregate only the rows
that entered it, finalise, write the rowThe plan: sort, then transform
The window step adds its own sort. PARTITION BY columns come first, then
ORDER BY columns, and the result is one stream per partition group:
// src/Processors/QueryPlan/WindowStep.cpp:85
// This resize is needed for cases such as `over ()` when we don't have a
// sort node, and the input might have multiple streams. The sort node would
// have resized it.
if (window_description.full_sort_description.empty())
pipeline.resize(1);
/// The sorting step before the window sorts only the main stream, but `WindowTransform` also processes the totals,
/// which an `arrayJoin` can expand into several unsorted rows.
if (!window_description.full_sort_description.empty())
SortingStep::fullSortStreams(
pipeline, sort_settings, window_description.full_sort_description, /*limit_=*/0,
/*skip_partial_sort=*/false, /*threshold_tracker=*/nullptr, /*sort_totals=*/true);
pipeline.addSimpleTransform(
[&](const SharedHeader & /*header*/)
{
return std::make_shared<WindowTransform>(
input_headers.front(), output_header, window_description, window_functions);
});The sort is not optional and it is not free. This is the price of the incremental frame: to advance a frame forward you need the rows in order.
Three nested bounds
The transform maintains three independent notions of “where are we”:
- the partition — the range of rows with the same
PARTITION BYkey; - the peer group — the range of rows with the same
ORDER BYkey, which is whatRANGEandGROUPSframes are built from; - the frame — the actual window, expressed in terms of
ROWS,RANGEorGROUPSboundaries.
Each is a small class with an advance method, and each is expressed as a
RowPoint: a block number, a row within the block, a row index within the
partition, and a peer-group index within the partition.
The partition is the outermost one, and it is discovered by scanning forward for the next row whose partition key differs:
// src/Processors/Transforms/Window/Partition.cpp:20
void Partition::advance(const SlidingBlocks & blocks)
{
RowPoint & end = partition_bounds.end;
while (!partition_bounds.fully_visible && end.location < blocks.end())
{
const SlidingBlock & block = blocks.blockAt(end.location.block);
int64_t next_start = end.location.row;
while (next_start < block.rows_count && !block.index.partition_starts[next_start])
++next_start;The partition_starts bitmap is computed once per block when the block
arrives, and it is a bitmap, not a set of row indices — one bit per row,
so a block of 65 536 rows costs 8 KB:
// src/Processors/Transforms/Window/SlidingIndexes.cpp:24
std::vector<bool> markPartitionStarts(const Columns & columns, size_t rows_count, const std::vector<size_t> & partition_by_indices, const std::optional<Columns> & previous_partition_key)
{
std::vector<bool> starts(rows_count, false);
starts[0] = !previous_partition_key || !haveSameKeys(*previous_partition_key, 0, columns, 0, partition_by_indices);
size_t next_start = getEqualRangeEndAssumeSorted(columns, partition_by_indices, 0, rows_count, /*nan_direction_hint=*/1);
while (next_start < rows_count)
{
starts[next_start] = true;
next_start = getEqualRangeEndAssumeSorted(columns, partition_by_indices, next_start, rows_count, /*nan_direction_hint=*/1);
}
return starts;
}Note the previous_partition_key parameter: a partition can straddle a
block boundary, so the last row of the previous block has to be compared
with the first row of this one. The code keeps the last row’s key columns
alive for exactly this:
// src/Processors/Transforms/Window/SlidingIndexes.cpp:82
Columns cutLastKey(const Columns & columns, size_t rows_count, const std::vector<size_t> & key_indices)
{
Columns last_row(columns.size());
for (const size_t key : key_indices)
last_row[key] = columns[key]->cut(rows_count - 1, 1);
return last_row;
}Peer groups get an index per row rather than a bitmap, because the frame machinery needs to know, for an arbitrary row, which peer group it is in:
// src/Processors/Transforms/Window/SlidingIndexes.cpp:39
std::vector<int64_t> assignPeerGroupIndexes(
const Columns & columns,
size_t rows_count,
const WindowTransformParams & params,
const std::vector<bool> & partition_starts,
const std::optional<Columns> & previous_order_key,
int64_t last_peer_group_index)
{
std::vector<int64_t> peer_group_indexes(rows_count);There is one subtlety here: under a ROWS frame every row is its own peer
group, so the whole index is just a counter:
// src/Processors/Transforms/Window/SlidingIndexes.cpp:49
const bool every_row_is_a_group = params.window_description.frame.type == WindowFrame::FrameType::ROWS;
if (!every_row_is_a_group && order_by_indices.empty())
return peer_group_indexes;Advancing the frame
The frame is the interesting one. Its two boundaries are computed by
advanceFrameStart and advanceFrameEnd, and the frame type decides which
of three mechanisms is used.
ROWS is trivial — the boundary is a row index:
// src/Processors/Transforms/Window/Frame.cpp:118
case WindowFrame::BoundaryType::Offset:
switch (frame.type)
{
case WindowFrame::FrameType::ROWS:
return advanceToRow(blocks, partition, current.row_index_in_partition + signedOffset(frame.begin_offset, frame.begin_preceding), start);
case WindowFrame::FrameType::RANGE:
return advanceToRangeBound(params, blocks, partition, current, frame.begin_offset, frame.begin_preceding, false, start);
case WindowFrame::FrameType::GROUPS:
return advanceToPeerGroup(blocks, partition, current.peer_group_index_in_partition + signedOffset(frame.begin_offset, frame.begin_preceding), start);
}GROUPS is a peer-group index, and finding the first row of a given peer
group means walking the per-row index forward:
// src/Processors/Transforms/Window/Frame.cpp:56
while (bound.location != partition_end)
{
const SlidingBlock & block = blocks.blockAt(bound.location.block);
const std::vector<int64_t> & groups = block.index.peer_group_indexes;
const int64_t rows_end = partition_end.block == bound.location.block ? partition_end.row : block.rows_count;
const int64_t row = groups[bound.location.row] >= group
? bound.location.row
: static_cast<int64_t>(findEqualRangeEndAssumeSorted(bound.location.row, rows_end, 16, [&](size_t i) { return groups[i] < group; }));RANGE is the hardest: the boundary is a value, not a position. RANGE BETWEEN 3 PRECEDING AND CURRENT ROW means “every row whose ORDER BY value is
at least current_value - 3”. The search is a linear scan over the sorted
key column with a comparator that shifts the current value by the offset:
// src/Processors/Transforms/Window/Frame.cpp:85
while (bound.location != partition_end)
{
const SlidingBlock & block = blocks.blockAt(bound.location.block);
const IColumn * key_column = block.materialized_columns[key_position].get();
const int64_t rows_end = partition_end.block == bound.location.block ? partition_end.row : block.rows_count;
int64_t row = bound.location.row;
while (row < rows_end && params.range_offset_comparator(key_column, row, current_key_column, current.location.row, offset, offset_is_preceding) * direction < past)
++row;The comparator is chosen once, at construction time, and specialised on the key’s type — there is no per-row type dispatch:
// src/Processors/Transforms/Window/WindowTransformParams.cpp:131
/// Null for every frame but RANGE OFFSET, which has exactly one ORDER BY key.
RangeOffsetComparator chooseRangeOffsetComparator(const Block & header, const WindowFrame & frame, const SortDescription & order_by, const std::vector<size_t> & order_by_indices)
{
if (!isRangeOffsetFrame(frame))
return nullptr;
chassert(order_by_indices.size() == 1);
const IColumn & column = *header.getByPosition(order_by_indices[0]).column;
const bool missing_is_greater = order_by[0].nulls_direction > 0;The incremental update
Here is the heart of it. After the frame bounds have moved, the aggregate state is updated — but only by the difference:
// src/Processors/Transforms/WindowTransform.cpp:169
// We might have to reset aggregation state and/or add some rows to it.
// Figure out what to do.
bool reset_aggregation = false;
RowNumber rows_to_add_start;
RowNumber rows_to_add_end;
if (current_frame.start.row_index_in_partition == prev_frame.start.row_index_in_partition)
{
// The frame start didn't change, add the tail rows.
reset_aggregation = false;
rows_to_add_start = prev_frame.end.location;
rows_to_add_end = current_frame.end.location;
}
else
{
// The frame start changed, reset the state and aggregate over the
// entire frame. This can be made per-function after we learn to
// subtract rows from some types of aggregation states, but for now we
// always have to reset when the frame start changes.
reset_aggregation = true;
rows_to_add_start = current_frame.start.location;
rows_to_add_end = current_frame.end.location;
}The comment is honest about the limitation: ClickHouse’s aggregate states
cannot have rows removed from them, so a frame whose start moved backwards
(or forwards) has to be recomputed from scratch. But the common case — the
default frame RANGE UNBOUNDED PRECEDING TO CURRENT ROW — has a fixed
start, so only the tail is added, every time.
The rows are then fed to the aggregate in a batch, block by block:
// src/Processors/Transforms/WindowTransform.cpp:217
for (auto block_number = rows_to_add_start.block;
block_number < past_the_end_block;
++block_number)
{
const auto & block = blocks.blockAt(block_number);
if (ws.cached_block_number != block_number)
{
for (size_t i = 0; i < ws.argument_column_indices.size(); ++i)
{
ws.argument_columns[i] = block.materialized_columns[
ws.argument_column_indices[i]].get();
}
ws.cached_block_number = block_number;
}The first and last block may be partial, the middle ones are whole, and the aggregate is fed a row range rather than a list of rows:
// src/Processors/Transforms/WindowTransform.cpp:233
// First and last blocks may be processed partially, and other blocks
// are processed in full.
const auto first_row = block_number == rows_to_add_start.block
? rows_to_add_start.row : 0;
const auto past_the_end_row = block_number == rows_to_add_end.block
? rows_to_add_end.row : block.rows_count;The frame that did not move
There is one more optimisation, and it is a big one for the default frame.
With RANGE UNBOUNDED PRECEDING TO CURRENT ROW and an ORDER BY on a
column with many duplicates, many consecutive rows share the same frame.
The result for all of them is identical.
So the transform checks whether the frame moved, and if it did not, copies the previous row’s result instead of finalising the aggregate again:
// src/Processors/Transforms/WindowTransform.cpp:275
if (frame_unchanged && !ws.is_aggregate_function_state && peers.current().location.row > 0)
{
// Same frame as the previous row -> same result. When that row is in this same block its
// result is already in result_column one position back, so copy it instead of
// re-finalizing. We copy the column into itself with insertRangeFrom (not insertFrom):
// insertRangeFrom appends via resize + memcpy from a disjoint source range, which is
// self-safe even if the append reallocates and even for nested columns (Array, Variant,
// Dynamic, JSON) whose sub-columns are not covered by the top-level reserve.
chassert(std::cmp_equal(result_column->size(), peers.current().location.row));
result_column->insertRangeFrom(*result_column, peers.current().location.row - 1, 1);
}The condition for “the frame did not move” is that both the start and the end are the same as the previous row’s:
// src/Processors/Transforms/WindowTransform.cpp:257
const FrameBounds & current_frame = frame.bounds();
const bool frame_unchanged = peers.current().row_index_in_partition > 0 && current_frame.start.row_index_in_partition == prev_frame.start.row_index_in_partition && current_frame.end.row_index_in_partition == prev_frame.end.row_index_in_partition;Note that copying the column into itself has to be done carefully:
insertRangeFrom appends via resize + memcpy from a disjoint source
range, so it is safe even if the append reallocates the buffer. A naive
insertFrom on the same column would be reading from memory that the
resize may have just moved.
True window functions do not use the aggregate at all
row_number, rank, dense_rank, lag, lead and friends are not
aggregates at all — they are computed from the position and the peer
bounds. They implement a separate interface:
// src/WindowFunctions/IWindowFunction.h:23
class IWindowFunction
{
public:
virtual ~IWindowFunction() = default;
// Must insert the result for current_row.
virtual void windowInsertResultInto(const WindowTransform * transform, size_t function_index) const = 0;
virtual std::optional<WindowFrame> getDefaultFrame() const { return {}; }
/// Is the frame type supported by this function.
virtual bool checkWindowFrameType(const WindowTransform * /*transform*/) const { return true; }
};rank is then a one-liner that reads the start of the peer group:
// src/WindowFunctions/WindowFunctionRank.cpp:33
void windowInsertResultInto(const WindowTransform * transform,
size_t function_index) const override
{
IColumn & to = *transform->blocks.blockAt(transform->peers.current().location.block)
.result_columns[function_index];
assert_cast<ColumnUInt64 &>(to).getData().push_back(
transform->peers.bounds().start.row_index_in_partition + 1);
}and row_number reads the current position:
// src/WindowFunctions/WindowFunctionRowNumber.cpp:22
void windowInsertResultInto(const WindowTransform * transform,
size_t function_index) const override
{
IColumn & to = *transform->blocks.blockAt(transform->peers.current().location.block)
.result_columns[function_index];
assert_cast<ColumnUInt64 &>(to).getData().push_back(
transform->peers.current().row_index_in_partition + 1);
}The difference between the two is exactly the difference between the
“with gaps” and “without gaps” semantics: rank is the first row of the
peer group, row_number is the row itself.
The streaming loop
Now the part that makes this hard: the transform is a streaming processor, but the frame’s end may not be knowable yet.
The frame’s end for RANGE CURRENT ROW depends on where the next
different ORDER BY value starts, and that may be in a block that has not
arrived yet. So the frame carries a fully_visible flag, and if it is not
set, the transform waits for more input:
// src/Processors/Transforms/WindowTransform.cpp:339
if (!frame.bounds().fully_visible)
{
// Wait for more input data to find the frame.
chassert(!input_is_finished);
chassert(!partition.bounds().fully_visible);
return;
}And the same applies to the partition’s end: the transform cannot start the next partition until it knows where the current one ends.
// src/Processors/Transforms/WindowTransform.cpp:315
void WindowTransform::computeReadyRows()
{
for (;;)
{
// Either we ran out of data or we found the end of partition (maybe
// both, but this only happens at the total end of data).
const RowNumber partition_end = partition.bounds().end.location;
chassert(partition.bounds().fully_visible || partition_end == blocks.end());The main loop is: advance the peer bounds, advance the frame, update the aggregate, write the row, move to the next row.
// src/Processors/Transforms/WindowTransform.cpp:331
while (peers.current().location < partition_end)
{
peers.advancePeerGroupBounds(blocks, partition.bounds());
checkInvariants(blocks, next_output_block_number, partition.bounds(), peers, frame.bounds(), prev_frame);
frame.advance(blocks, peers.current(), partition.bounds());
checkInvariants(blocks, next_output_block_number, partition.bounds(), peers, frame.bounds(), prev_frame);The checkInvariants calls are no-ops in release builds, and they are quite
specific — they assert, among other things, that the frame never moves
backwards and that the previous frame’s start is never after the current
one:
// src/Processors/Transforms/WindowTransform.cpp:81
chassert(partition.start.location <= prev_frame.start.location);
chassert(prev_frame.start.location <= prev_frame.end.location);
chassert(prev_frame.end.location <= partition.end.location);
chassert(prev_frame.start.row_index_in_partition <= prev_frame.end.row_index_in_partition);
chassert(prev_frame.end.row_index_in_partition <= partition.end.row_index_in_partition);
if (frame.fully_visible)
{
chassert(prev_frame.start.location <= frame.start.location);
chassert(frame.start.location <= frame.end.location);
chassert(prev_frame.end.location <= frame.end.location);Releasing memory
Because the frame moves forward, the blocks behind it are dead weight. The transform drops them:
// src/Processors/Transforms/WindowTransform.cpp:560
void WindowTransform::releaseUnusedBlocks()
{
// We don't really have to keep the entire partition, and it can be big, so
// we want to drop the starting blocks to save memory. We can drop the old
// blocks if we already returned them as output, and the frame and the
// current row are already past them. The previous frame start is never
// after the current frame start, so we don't have to check the latter. Note
// that the frame start can be further than current row for some frame specs
// (e.g. EXCLUDE CURRENT ROW), so we have to check both.
const auto first_used_block = std::min({next_output_block_number, prev_frame.start.location.block, peers.current().location.block});
while (blocks.begin().block < first_used_block)
blocks.pop();
}The three-way minimum is worth reading twice. A block can be released when
it has been output (next_output_block_number), when the frame has moved
past it (prev_frame.start), and when the current row has moved past it
(peers.current). The comment notes the EXCLUDE CURRENT ROW case, where
the frame start can be after the current row — a frame that excludes the
current row would otherwise pin a block in memory.
The arena is reset per partition, so a partition’s aggregate states do not accumulate memory across partitions:
// src/Processors/Transforms/WindowTransform.cpp:429
// Replace the arena so that it does not grow across partitions. All states
// were destroyed above and no result lives in it, see the field comment.
if (arena)
{
arena = std::make_unique<Arena>();
}What the aggregate state can and cannot do
The incremental trick has a hard limit that the code states plainly: an aggregate state cannot have a row removed from it. So the frame is recomputed from scratch whenever its start moves:
// src/Processors/Transforms/WindowTransform.cpp:183
// The frame start changed, reset the state and aggregate over the
// entire frame. This can be made per-function after we learn to
// subtract rows from some types of aggregation states, but for now we
// always have to reset when the frame start changes.
In practice this matters for frames like ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING: the start moves on every row, so every row re-aggregates the
whole frame — which is fine, because the frame is small. It would matter
for ROWS BETWEEN 100 PRECEDING AND CURRENT ROW: the start moves on every
row and the frame is large, so that frame is quadratic again.
sum() and count() could support subtraction; avg() could be derived
from them; min()/max() cannot (you would need a multiset). The comment
says this could be made per-function, and is not.
Trade-offs
What the design buys:
- Linear time for the common frames. With a fixed frame start, each row is aggregated exactly once, ever.
- A copy instead of a recomputation when the frame does not move. For
ORDER BYcolumns with many duplicates, most rows are free. - Streaming memory. The transform keeps only the blocks the frame still needs, and drops the rest as it goes.
- True window functions are nearly free.
rankandrow_numberread the peer bounds directly; there is no aggregate state at all.
What it costs:
- A full sort of the input.
PARTITION BY+ORDER BYhas to be materialised, which for a large input means a disk-backed sort. - Frames with a moving start are quadratic.
ROWS BETWEEN N PRECEDING AND CURRENT ROWre-aggregates the whole frame for every row, because the aggregate states cannot have rows removed. RANGEwith an offset is a linear scan. The boundary search walks the key column row by row rather than binary-searching, because the boundary is a value and the search predicate is a shifted comparison.- The frame’s end may not be knowable. For
RANGE CURRENT ROW, the transform must wait for the next block before it can finalise a row, which adds latency to the first rows of every partition. - The peer-group index costs 8 bytes per row.
int64_tper row, for every block, even when the frame is aROWSframe that does not need it.
Wrapping up
The one thing to remember: the window transform keeps the aggregate state alive between rows and only feeds it the rows that entered the frame. That is the entire difference between O(n²) and O(n).
The part people get wrong: the incremental update only works when the
frame’s start is fixed. The default frame
(RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) has a fixed start and
is linear. A frame like ROWS BETWEEN 100 PRECEDING AND CURRENT ROW has a
moving start and is quadratic again, and no setting changes that.
What to go do about it: if a window query is slow, look at the frame
first, then at the sort. A window over an already-sorted table
(optimize_read_in_order) skips the sort entirely. And if the frame is
ROWS BETWEEN N PRECEDING AND CURRENT ROW with a large N, consider
whether RANGE on a quantised key would give the same answer with a fixed
start.