learning.lab
SQL & Querying· 11/33

Window Functions

OVER, frames, and SAMPLE

GROUP BY collapses rows into one row per group. Sometimes you want the aggregate alongside every original row instead — a running total next to each transaction, a rank next to each score. That's what window functions are for.

The shape of a window function

SELECT
    user_id,
    event_time,
    amount,
    sum(amount) OVER (
        PARTITION BY user_id
        ORDER BY event_time
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) AS running_total
FROM payments;

PARTITION BY groups rows the same way GROUP BY would, but every row in the group is kept. ORDER BY inside OVER (...) defines the order the window function walks rows in within each partition — it has nothing to do with the table's ORDER BY (the primary key). The frame clause, ROWS BETWEEN ... AND ..., controls exactly which rows around the current one are included in the calculation; omitting it defaults to the whole partition for most aggregate functions used this way.

Common functions

  • row_number() — a unique, gapless sequence per partition.
  • rank() / dense_rank() — ranking with (rank) or without (dense_rank) gaps after ties.
  • lag(col, n) / lead(col, n) — the value of a column n rows before/after the current one in the ordered partition — useful for period-over-period comparisons.
  • sum, avg, min, max used with OVER (...) instead of GROUP BY.
rank users by spend, keep every row
SELECT
    user_id,
    total_spend,
    rank() OVER (ORDER BY total_spend DESC) AS spend_rank
FROM user_totals;
Under the hood
Window functions run as a distinct processing step after aggregation and filtering but before the final ORDER BY/LIMIT of the outer query — you can reference a window function's output in an outer WHERE only via a subquery or CTE, the same restriction most SQL engines share.

SAMPLE: trading accuracy for speed

Separately from window functions, but in the same spirit of "SQL features that behave differently here": the SAMPLE clause lets a query run against a deterministic fraction of a table's rows instead of all of them, for fast approximate answers on huge tables during exploration.

CREATE TABLE events
(
    user_id UInt64,
    event   String
)
ENGINE = MergeTree
ORDER BY user_id
SAMPLE BY cityHash64(user_id);

SELECT count() * 10 AS approx_count
FROM events
SAMPLE 0.1;

SAMPLE only works on a table that declares a SAMPLE BY expression (usually a hash of some column, so the sampling is deterministic and evenly distributed rather than arbitrary). SAMPLE 0.1 reads roughly 10% of the data; results for aggregates need to be scaled back up manually, as shown above — ClickHouse doesn't do that scaling for you.

Production note
SAMPLE is for exploratory or dashboard queries where an approximate number returned in milliseconds beats an exact number returned in seconds — not for anything where correctness matters, like billing or financial reporting.