open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 04 · 06

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.

SQL Senior ◷ 16 min
Level
FoundationsJuniorMiddleSenior

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):

  1. LAG(ts) gives the previous event’s timestamp for each row.
  2. A boundary flag is 1 when ts - prev_ts > interval '30 min' (or the first event), else 0.
  3. A cumulative SUM of 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.

Quiz

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?

Quiz

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?

Complete the analogy

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.

Recall before you leave
  1. 01
    Describe the three steps of sessionization with window functions.
  2. 02
    What is the row_number difference trick for gaps-and-islands and why does it work?
  3. 03
    Why do multiple window functions sometimes make a query slow, and how do you reduce it?
Recap

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.

recallapplystretch0 of 8 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.