Tags: web-dev analytics concept
Window Functions
Date: 2026-09-27
A calculation across related rows that keeps every row.
GROUP BYcollapses a group to one line;OVER (...)looks at the group and writes the answer onto each member. Most of analytics — sessionising, ranking, running totals, “previous order”, deduplication — is this.
A window function computes a value for each row from a set of rows related to it — its window — without merging those rows together.
Same data, both ways
orders
customer ordered_at total
ana 2026-01-03 40
ana 2026-02-10 25
ana 2026-05-01 60
ben 2026-01-20 30
GROUP BY customer SUM(total) OVER (PARTITION BY customer)
customer spend customer ordered_at total spend
ana 125 ana 2026-01-03 40 125
ben 30 ana 2026-02-10 25 125
ana 2026-05-01 60 125
rows collapsed ben 2026-01-20 30 30
every row kept, answer repeated
Now each order can be compared with its customer’s total — total / spend is each order’s share — which GROUP BY can’t do without joining the aggregate back on.
The anatomy
function() OVER (
PARTITION BY customer -- which rows belong together (optional: whole table)
ORDER BY ordered_at -- order inside each partition (needed for ranks, LAG, running totals)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- the frame (optional)
)PARTITION BYis the grouping. Leave it out and the window is the whole resultORDER BYinsideOVERis separate from the query’s ownORDER BY. It orders the window, not the output- The frame narrows the window to a range around the current row. Needed for running totals and moving averages; irrelevant to
ROW_NUMBERandLAG
The functions worth knowing
ana's three orders, PARTITION BY customer ORDER BY ordered_at
ordered_at total ROW_NUMBER LAG(ordered_at) running SUM FIRST_VALUE(ordered_at)
2026-01-03 40 1 NULL 40 2026-01-03
2026-02-10 25 2 2026-01-03 65 2026-01-03
2026-05-01 60 3 2026-02-10 125 2026-01-03
ROW_NUMBER()— 1, 2, 3 within each partition. First order, latest event, deduplicationRANK()/DENSE_RANK()— likeROW_NUMBER, but ties share a number.RANKleaves gaps after a tie (1, 1, 3);DENSE_RANKdoesn’t (1, 1, 2)LAG(x)/LEAD(x)— the value from the previous or next row. Days between orders, the page before this oneSUM/AVG/COUNTwith a frame — running totals, seven-day moving averagesFIRST_VALUE/LAST_VALUE— the acquisition channel of a customer’s first order, stamped on every order.LAST_VALUEneeds an explicit frame, or it returns the current row — see belowNTILE(n)— splits a partition into n buckets. Deciles of customers by spend — RFM Segmentation
The analytics jobs it does
Sessionising a raw event stream. Gap from the previous event, a flag where the gap exceeds 30 minutes, then a running sum of the flags is the session number:
WITH gaps AS (
SELECT user_id, event_time,
CASE
WHEN LAG(event_time) OVER w IS NULL THEN 1 -- first event starts a session
WHEN TIMESTAMP_DIFF(event_time, LAG(event_time) OVER w, MINUTE) > 30 THEN 1 -- long gap starts one
ELSE 0
END AS is_new_session
FROM events
WINDOW w AS (PARTITION BY user_id ORDER BY event_time)
)
SELECT *,
SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY event_time) AS session_number
-- running total of the 1s: 1,1,2,3 → each 1 bumps the session count
FROM gaps;TIMESTAMP_DIFF is BigQuery’s spelling; Postgres subtracts timestamps and reads the interval — SQL Dialects. This is the whole mechanism behind Sessionisation done at query time, and why a warehouse can re-sessionise history at a different timeout — Warehouse-First Analytics.
Deduplicating events — keep one row per event_id, the earliest:
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY event_id ORDER BY received_at) AS rn
FROM events
) WHERE rn = 1;Where the dialect supports it, QUALIFY rn = 1 does the same without the subquery — Idempotency and Deduplication.
Also routinely: first-touch channel per customer, order number (first vs repeat purchase), days since previous order for Cohort Analysis, and percent of total.
Why you can’t filter on one in WHERE
SELECT *, ROW_NUMBER() OVER (...) AS rn FROM events WHERE rn = 1; -- errorWindow functions are computed after WHERE, GROUP BY and HAVING — nearly last, just before ORDER BY and LIMIT. So WHERE can’t see them, and filtering needs a subquery or CTE wrapped around them, or QUALIFY — Aggregation and Grouping · Common Table Expressions.
The same ordering means a WHERE clause changes the window. Filtering to March before ROW_NUMBER makes a customer’s first March order “order 1”, which isn’t their first order.
Traps
- The default frame. With
ORDER BYin the window and no frame stated, the frame is “start of partition to the current row” — which is whySUM(...) OVER (ORDER BY ...)is a running total, not a total. It’s also whyLAST_VALUEreturns the current row. State the frame when it matters:ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ROWSvsRANGE.ROWScounts physical rows.RANGEtreats rows with equalORDER BYvalues as one step, so ties all get the same running total. The default frame isRANGE, which surprises people with duplicate timestamps- Ties make
ROW_NUMBERnon-deterministic. Two events with the same timestamp can swap between runs. Add a tie-breaker column to theORDER BY - Cost. Each distinct
PARTITION BY ... ORDER BYis a sort. On a large events table several different windows mean several sorts — reuse one namedWINDOWclause where the dialect allows — Query Performance