Skip to content

TL;DR

Four window functions for “reach across rows in the partition without a self-join”:

  • LAG(col, n=1, default=NULL) — value of col from the row n back in the partition.
  • LEAD(col, n=1, default=NULL) — same, n rows ahead.
  • FIRST_VALUE(col) — value of col in the first row of the frame.
  • LAST_VALUE(col) — value of col in the last row of the frame.

LAG and LEAD are the workhorses for time-series differencing (“delta from previous event”), session detection (“gap > 30 min”), prev/next state (“previous status”), and a dozen other patterns. The self-join equivalent of any of these is at least 5x more code and usually slower.

FIRST_VALUE / LAST_VALUE have a famous gotcha: by default, the frame is “start through current row”, so LAST_VALUE returns the current row, not the partition’s last row. Always specify the frame.

A worked example

CREATE TABLE events (
    user_id int, ts timestamptz, event text, value numeric
);
INSERT INTO events VALUES
  (1, '2026-04-01 10:00', 'view',  NULL),
  (1, '2026-04-01 10:05', 'click', NULL),
  (1, '2026-04-01 10:10', 'add',     50),
  (1, '2026-04-01 11:30', 'view',  NULL),       -- new session: 80-min gap
  (1, '2026-04-01 11:35', 'buy',     50),
  (2, '2026-04-01 09:00', 'view',  NULL),
  (2, '2026-04-01 09:01', 'buy',     20);

LAG — previous event’s timestamp

SELECT user_id, ts, event,
       LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) AS prev_ts,
       ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts) AS gap
FROM   events
ORDER  BY user_id, ts;
+---------+------------------+-------+------------------+----------+
| user_id | ts               | event | prev_ts          | gap      |
+---------+------------------+-------+------------------+----------+
|    1    | 2026-04-01 10:00 | view  | NULL             | NULL     |
|    1    | 2026-04-01 10:05 | click | 2026-04-01 10:00 | 00:05:00 |
|    1    | 2026-04-01 10:10 | add   | 2026-04-01 10:05 | 00:05:00 |
|    1    | 2026-04-01 11:30 | view  | 2026-04-01 10:10 | 01:20:00 |
|    1    | 2026-04-01 11:35 | buy   | 2026-04-01 11:30 | 00:05:00 |
|    2    | 2026-04-01 09:00 | view  | NULL             | NULL     |
|    2    | 2026-04-01 09:01 | buy   | 2026-04-01 09:00 | 00:01:00 |
+---------+------------------+-------+------------------+----------+

The first row of each partition has no predecessor — LAG returns the default (NULL). To use 0 instead: LAG(ts, 1, '1970-01-01'::timestamptz).

Sessionization — flag a new session when gap > 30 min

WITH gaps AS (
    SELECT user_id, ts, event,
           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
)
SELECT user_id, ts, event,
       SUM(is_new_session) OVER (PARTITION BY user_id ORDER BY ts) AS session_id
FROM   gaps
ORDER  BY user_id, ts;
+---------+------------------+-------+------------+
| user_id | ts               | event | session_id |
+---------+------------------+-------+------------+
|    1    | 2026-04-01 10:00 | view  |     1      |
|    1    | 2026-04-01 10:05 | click |     1      |
|    1    | 2026-04-01 10:10 | add   |     1      |
|    1    | 2026-04-01 11:30 | view  |     2      |   ← new session
|    1    | 2026-04-01 11:35 | buy   |     2      |
|    2    | 2026-04-01 09:00 | view  |     1      |
|    2    | 2026-04-01 09:01 | buy   |     1      |
+---------+------------------+-------+------------+

The pattern: LAG measures gaps; CASE flags session boundaries; a running SUM over the flags assigns sequential session IDs. This is the canonical SQL sessionization. No self-join required.

LAG / LEAD with offsets and defaults

LAG(col)               -- 1 row back, default NULL
LAG(col, 3)            -- 3 rows back
LAG(col, 1, 0)         -- 1 row back, default 0 if no previous row
LEAD(col, 1, 'unknown') -- 1 row ahead, default 'unknown'

The default argument is the value returned when the offset goes out of the partition’s bounds. Hugely useful for “first event ever” features where you want 0 instead of NULL for the boundary.

FIRST_VALUE and LAST_VALUE — the frame trap

-- WRONG: LAST_VALUE returns the current row
SELECT user_id, ts, event,
       FIRST_VALUE(event) OVER (PARTITION BY user_id ORDER BY ts) AS first_event,
       LAST_VALUE(event)  OVER (PARTITION BY user_id ORDER BY ts) AS last_event
FROM   events;

first_event correctly returns the user’s first event. last_event returns the current row’s event — because the default frame for an ordered window is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, and the “last” row of that frame is the current row.

Fix: specify the full-partition frame:

LAST_VALUE(event) OVER (
    PARTITION BY user_id
    ORDER BY ts
    ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_event

Or use FIRST_VALUE with a reversed sort (often clearer):

FIRST_VALUE(event) OVER (PARTITION BY user_id ORDER BY ts DESC) AS last_event

This is the most-cited window-function gotcha. Memorize it.

NTH_VALUE

NTH_VALUE(col, n) — value of col in the n-th row of the frame. Same frame issues as FIRST_VALUE/LAST_VALUE. Less common; useful for “value of the second purchase” without writing a CTE.

Forward-fill / last-non-null with LAG

A frequent need: a sparse time-series with NULLs you want to forward-fill.

-- daily snapshots, occasionally missing
CREATE TABLE prices (day date, price numeric);
INSERT INTO prices VALUES
  ('2026-04-01', 100), ('2026-04-02', NULL), ('2026-04-03', NULL),
  ('2026-04-04', 110), ('2026-04-05', NULL);

-- forward-fill: copy the last non-null price forward
WITH grouped AS (
    SELECT day, price,
           COUNT(price) OVER (ORDER BY day) AS grp
    FROM   prices
)
SELECT day,
       FIRST_VALUE(price) OVER (PARTITION BY grp ORDER BY day) AS filled
FROM   grouped
ORDER  BY day;

The trick: COUNT(price) OVER (ORDER BY day) increments only on non-NULL rows, so each NULL run shares a grp value with the last non-NULL row. Within each group, FIRST_VALUE(price) is the non-NULL seed. Output:

+------------+--------+
| day        | filled |
+------------+--------+
| 2026-04-01 | 100    |
| 2026-04-02 | 100    |
| 2026-04-03 | 100    |
| 2026-04-04 | 110    |
| 2026-04-05 | 110    |
+------------+--------+

This is one of those “I needed this for years before someone showed it to me” tricks. Keep it.

Common pitfalls

  • LAST_VALUE returns the current row unless you specify the full partition frame. See above.
  • LAG/LEAD over a window with no ORDER BY is an error in Postgres (rightly — “previous in what order?”).
  • Using LAG with PARTITION BY that doesn’t include all the identifying columns gives wrong answers. If you care about per-user per-product gaps, partition by both.
  • Forgetting the default arg. First row’s LAG is NULL by default. Downstream LAG(...) > 5 filters NULL out — usually fine, sometimes a bug.
  • NULL values in the ordering column. LAG follows the same NULL ordering rules as ORDER BY. Pin them with NULLS FIRST/LAST.

Production patterns for ML

1. Time-since-last-event features. A standard churn / engagement feature:

SELECT user_id, ts,
       EXTRACT(EPOCH FROM (ts - LAG(ts) OVER (PARTITION BY user_id ORDER BY ts)))
           AS seconds_since_prev,
       EXTRACT(EPOCH FROM (LEAD(ts) OVER (PARTITION BY user_id ORDER BY ts) - ts))
           AS seconds_until_next
FROM   events;

LEAD looks forward — useful for label generation (“did the user return within 7 days?”).

2. State-change detection. “Did this user’s plan change?”:

SELECT user_id, day, plan,
       LAG(plan) OVER (PARTITION BY user_id ORDER BY day) AS prev_plan,
       CASE WHEN plan <> LAG(plan) OVER (PARTITION BY user_id ORDER BY day)
            THEN 1 ELSE 0 END AS is_change
FROM   user_plan_daily;

Sum is_change over a window to count plan changes per user — a strong churn signal.

3. Forward-fill latest known feature value. Common when joining sparse feature snapshots to dense event streams:

WITH dense AS (
    SELECT day, COALESCE(score, LAG(score) IGNORE NULLS
                                OVER (ORDER BY day)) AS score
    FROM   user_score_snapshots
)
SELECT * FROM dense;

IGNORE NULLS is supported by Snowflake, BigQuery, DuckDB, Oracle. Postgres doesn’t have it directly — use the COUNT-based trick from above.

Resources