Real-world window patterns: gaps, islands, sessions
Gaps-and-islands detects consecutive runs via the row_number difference trick; sessionization flags a new session when LAG on the timestamp shows a gap over threshold, then SUMs the flags. Each distinct window ORDER BY adds a Sort/WindowAgg node — order windows to share sorts.
Product asks: “how many distinct sessions did each user have, where a session ends after 30 minutes of inactivity?” There is no session_id in the events table — sessions are implicit in the gaps between timestamps. The naive answer is a loop in application code pulling every event and tracking state; on 50M events it runs for hours. The window answer is three lines: LAG the timestamp, flag the rows where the gap exceeds 30 minutes, then run a cumulative SUM of those flags. That is sessionization, and it is the crown jewel of window patterns.
Sessionization: LAG a timestamp, flag gaps, cumulative-SUM the flags
Sessionization assigns a session number to each event so that events within an inactivity gap belong to the same session. The recipe is three windowed steps over (PARTITION BY user_id ORDER BY ts):
LAG(ts)gives the previous event’s timestamp for each row.- A boundary flag is 1 when
ts - prev_ts > interval '30 min'(or the first event), else 0. - A cumulative
SUMof the flag turns those 1s into a monotonically increasing session number — every flag bumps the counter, every non-flag keeps it.
SELECT user_id, ts,
SUM(is_new_session) OVER (
PARTITION BY user_id ORDER BY ts
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS session_id
FROM (
SELECT user_id, ts,
CASE WHEN ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)
> interval '30 minutes'
OR LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) IS NULL
THEN 1 ELSE 0 END AS is_new_session
FROM events
) flagged;Gaps-and-islands: the row_number difference trick
“Islands” are runs of consecutive values; “gaps” are the holes between them. The classic problem: given login dates, find each streak of consecutive days. The trick is that for a run of consecutive integers (or dates), the value minus its row number is constant within a run, because both increase by 1 in lockstep. When the sequence skips, the difference changes — a new island.
SELECT user_id, MIN(login_date) AS streak_start,
MAX(login_date) AS streak_end, COUNT(*) AS streak_len
FROM (
SELECT user_id, login_date,
login_date - (ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_date))::int AS grp
FROM daily_logins
) t
GROUP BY user_id, grp
ORDER BY user_id, streak_start;grp is the anchor: all rows in one consecutive streak share the same login_date - row_number value, so GROUP BY grp collapses each streak into one summary row. This is the canonical gaps-and-islands solution — ROW_NUMBER plus arithmetic plus a GROUP BY on the derived key. Dedup (lesson 4) and cumulative distributions (lesson 5) are smaller members of the same family: every one is “use a window to derive a key, then aggregate or filter on it.”
Performance: each distinct window ORDER BY costs a sort
Window functions are not free. Each distinct window definition — specifically each unique PARTITION BY ... ORDER BY ... — forces Postgres to materialize the rows in that order, which means a Sort node feeding a WindowAgg node in the plan. Stack three windows with three different orderings and you get three sorts; on a large table that is the dominant cost.
The optimization is to make windows share their ordering. Postgres reuses a single sort for all window functions that have the same PARTITION BY/ORDER BY, so writing SUM(...) OVER w, ROW_NUMBER() OVER w, LAG(...) OVER w against one named WINDOW w AS (PARTITION BY user_id ORDER BY ts) lets them ride one sort instead of three. When you genuinely need different orderings, order the window clauses so that compatible sorts are adjacent — the planner can sometimes pipeline a sort into the next if it is a prefix. Always confirm with EXPLAIN ANALYZE: count the Sort and WindowAgg nodes and watch for Sort Method: external merge Disk: which means the sort spilled past work_mem and is hitting disk — the moment a window query goes from milliseconds to seconds.
▸Why this works
Why does sharing a sort matter so much? A sort is O(n log n) and, worse, becomes O(disk) the moment its working set exceeds work_mem — Postgres spills to temp files and the query can slow by 10–100×. A single 50M-row sort that fits in memory might take 2 seconds; the same sort spilling to disk can take 40. Three separate spilling sorts is three times that pain. Consolidating windows onto one ordering, and bumping work_mem for the session running a heavy analytic query, are the two levers. The databases track’s execution-plans chapter shows how to read the Sort/WindowAgg/Incremental Sort nodes and spot the disk spill; here the rule is simply: fewer distinct window orderings, verified in the plan.
In sessionization, after you flag each row 1 when the gap exceeds the threshold and 0 otherwise, what turns those flags into a session id?
A report uses three window functions with three different PARTITION BY/ORDER BY clauses and is slow. What does EXPLAIN ANALYZE most likely show, and what's the fix?
Fill in the blank: in gaps-and-islands, all rows in one run of consecutive values share the same value-minus-_______ , so grouping on that derived key collapses each run into one summary row.
- 01Describe the three steps of sessionization with window functions.
- 02What is the row_number difference trick for gaps-and-islands and why does it work?
- 03Why do multiple window functions sometimes make a query slow, and how do you reduce it?
The real power of window functions is that a handful of primitives compose into the analytics patterns teams actually need, all sharing one shape: use a window to derive a key, then aggregate or filter on it. Sessionization is the showcase — LAG the timestamp, flag rows where the inactivity gap crosses a threshold, and run a cumulative SUM of those 0/1 flags to mint a session id per event, replacing an hours-long application loop with three windowed lines. Gaps-and-islands uses the value - ROW_NUMBER trick: consecutive values share a constant derived key, so a GROUP BY on it collapses each run into a summary; dedup and cumulative distributions are smaller cousins of the same idea. The senior caveat is performance: every distinct PARTITION BY/ORDER BY is a separate Sort + WindowAgg, and a sort that outgrows work_mem spills to disk and slows the query by an order of magnitude — so share one window ordering across functions, watch the Sort/WindowAgg node count in EXPLAIN ANALYZE, and bump work_mem for heavy analytic sessions. Now when you face a “how many sessions?” or “find the streaks” requirement, reach for the flag-then-cumsum shape before you reach for a loop — three window lines beat hours of application code, and EXPLAIN ANALYZE will tell you if you need to consolidate the sorts.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.