Readineer
PATTERN 17SQL Patterns / Analytical & Sequence

SQL LAG and LEAD: comparing rows across time and sequence

Learn how to compare each row with earlier or later rows using LAG and LEAD, find first and last values within a group, measure changes between events, and avoid mistakes when sequence or window frames are unclear.

8 min read10 sections2 predictions
01Foundation

Sometimes the important value is on another row

Many analytical questions are not about one row in isolation. They compare one event with another. Consider the following orders table, where each row represents one order:

Orders
order_idcustomerordered_atorder_amount
1001Asha2026-07-01 09:15:001500
1002Ben2026-07-02 14:30:003200
1003Asha2026-07-10 11:00:002200
1004Ben2026-07-18 16:20:003600
1005Asha2026-07-31 18:30:00900
1006Ben2026-08-01 08:00:005000

Suppose we want to answer how much did each customer’s order value change compared with their previous order? For Asha, the sequence is July 1 (1500), July 10 (2200), July 31 (900). To calculate the change for the July 10 order, we need both the current order (2200) and the previous order (1500), so the difference is 2200 - 1500 = 700. The previous value lives on another row. This is exactly the kind of problem LAG is designed to solve.

02Looking backward

LAG: look at an earlier row

LAG returns a value from a previous row in the analytical sequence. The basic form is LAG(value) OVER (ORDER BY sequence_column). Suppose we want to show each order beside the previous order amount:

Query
SELECT
    order_id,
    customer,
    ordered_at,
    order_amount,
    LAG(order_amount) OVER (
        ORDER BY ordered_at, order_id
    ) AS previous_order_amount
FROM orders
ORDER BY
    ordered_at,
    order_id;
Result
order_idcustomerorder_amountprevious_order_amount
1001Asha1500NULL
1002Ben32001500
1003Asha22003200
1004Ben36002200
1005Asha9003600
1006Ben5000900

SQL has followed the overall chronological order. But that is not actually our requirement: we wanted the previous order for the same customer. For that, we need PARTITION BY.

LAG within each customer

Query
SELECT
    order_id,
    customer,
    ordered_at,
    order_amount,
    LAG(order_amount) OVER (
        PARTITION BY customer
        ORDER BY ordered_at, order_id
    ) AS previous_order_amount
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;
Result
order_idcustomerorder_amountprevious_order_amount
1001Asha1500NULL
1003Asha22001500
1005Asha9002200
1002Ben3200NULL
1004Ben36003200
1006Ben50003600

Now the sequence restarts for each customer. For Asha, 1500 has no previous, then 2200 follows 1500, then 900 follows 2200. For Ben, 3200 has no previous, then 3600 follows 3200, then 5000 follows 3600.

PARTITION BY customer          Which rows belong to the same sequence?
ORDER BY ordered_at, order_id  What is the sequence within that customer?
LAG(order_amount)              Which earlier value should be returned?

Why the first row has NULL

For Asha’s first order (1001 | 1500), there is no earlier Asha order, so LAG(order_amount) returns NULL. The same happens for Ben’s first order. That NULL has a useful meaning: no previous row exists in this partition. It is not necessarily missing source data; it can be a natural consequence of the sequence.

Calculating change from the previous row

Once we have the previous value, we can compare it with the current value:

Query
SELECT
    order_id,
    customer,
    order_amount,
    LAG(order_amount) OVER (
        PARTITION BY customer
        ORDER BY ordered_at, order_id
    ) AS previous_order_amount,
    order_amount
        - LAG(order_amount) OVER (
            PARTITION BY customer
            ORDER BY ordered_at, order_id
        ) AS amount_change
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;
Result
order_idcustomerorder_amountprevious_order_amountamount_change
1001Asha1500NULLNULL
1003Asha22001500700
1005Asha9002200-1300
1002Ben3200NULLNULL
1004Ben36003200400
1006Ben500036001400

For Asha’s second order, 2200 - 1500 = 700 (increased by 700); for her third, 900 - 2200 = -1300 (decreased by 1300). The pattern current_value - LAG(current_value) appears frequently and can measure price changes, balance changes, daily revenue changes, sensor changes, score changes, and differences between consecutive events.

Comparing percentage change

Sometimes the business wants the relative change, (current - previous) / previous. A CTE keeps the query readable:

Query
WITH order_changes AS (
    SELECT
        order_id,
        customer,
        ordered_at,
        order_amount,
        LAG(order_amount) OVER (
            PARTITION BY customer
            ORDER BY ordered_at, order_id
        ) AS previous_order_amount
    FROM orders
)
SELECT
    order_id,
    customer,
    order_amount,
    previous_order_amount,
    100.0
        * (order_amount - previous_order_amount)
        / NULLIF(previous_order_amount, 0)
        AS percentage_change
FROM order_changes
ORDER BY
    customer,
    ordered_at,
    order_id;

NULLIF protects the calculation if the previous amount happens to be zero. For the first order in each partition, previous_order_amount is already NULL, so the percentage change also remains NULL.

Comparing event times with LAG

LAG is not limited to numeric values. To answer how much time passed between each customer’s orders, lag the timestamp itself and subtract:

Query
SELECT
    order_id,
    customer,
    ordered_at,
    ordered_at
        - LAG(ordered_at) OVER (
            PARTITION BY customer
            ORDER BY ordered_at, order_id
        ) AS time_since_previous_order
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;

This answers questions such as days since previous purchase, time since previous login, time between status changes, delay between sensor readings, and time between customer events. The same sequence logic applies whether the value is numeric, text, or temporal.

03Looking forward

LEAD: look at a later row

LEAD is the opposite of LAG. LAG looks backward (previous row into current row); LEAD looks forward (current row into next row). Suppose we want the time of each customer’s next order:

Query
SELECT
    order_id,
    customer,
    ordered_at,
    LEAD(ordered_at) OVER (
        PARTITION BY customer
        ORDER BY ordered_at, order_id
    ) AS next_ordered_at
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;
Result
order_idcustomerordered_atnext_ordered_at
1001Asha2026-07-01 09:152026-07-10 11:00
1003Asha2026-07-10 11:002026-07-31 18:30
1005Asha2026-07-31 18:30NULL
1002Ben2026-07-02 14:302026-07-18 16:20
1004Ben2026-07-18 16:202026-08-01 08:00
1006Ben2026-08-01 08:00NULL

The final order for each customer has next_ordered_at = NULL because no later row exists.

LAG vs LEAD

The distinction is simple: LAG(value) asks what value came before this row, and LEAD(value) asks what value comes after this row. For a sequence A, B, C, D:

LAG
currentLAG
ANULL
BA
CB
DC
LEAD
currentLEAD
AB
BC
CD
DNULL

Looking more than one row away

Both functions can accept an offset. LAG(order_amount, 2) returns the value two rows before the current row. For 100, 200, 300, 400, the result is NULL, NULL, 100, 200. Similarly, LEAD(order_amount, 2) looks two rows forward. This is useful when comparisons are based on a fixed number of observations rather than only adjacent rows.

Providing a default value

LAG and LEAD can also accept a default value, LAG(value, offset, default). For example, LAG(order_amount, 1, 0) returns 0 when no previous row exists.

Be careful. Replacing “no previous order exists” with “previous order amount = 0” changes the meaning; those are not automatically the same thing. Usually, keeping the natural NULL is clearer unless the business explicitly defines a meaningful default.
04The featured failure

Comparing the previous row from the wrong sequence

Suppose the requirement is compare every order with the previous order for the same customer. Someone writes a valid query, but there is no PARTITION BY customer:

PAUSE & PREDICTFor Asha's July 10 order (1003, 2200), which value becomes previous_order_amount?
Query
SELECT
    order_id,
    customer,
    order_amount,
    LAG(order_amount) OVER (
        ORDER BY ordered_at, order_id
    ) AS previous_order_amount
FROM orders;

-- A: Asha's July 1 order amount, 1500
-- B: Ben's July 2 order amount, 3200
-- C: NULL
-- D: SQL raises an error
Your prediction

The fix: define the correct partition

Add PARTITION BY customer so each customer’s sequence is independent: Asha runs 1500 -> 2200 -> 900 and Ben runs 3200 -> 3600 -> 5000. The previous row for Asha can never come from Ben’s partition.

Key idea
Before asking for the previous or next row, define what sequence the row belongs to.

Deterministic sequence matters

Suppose two orders for Asha have exactly the same ordered_at and the window contains only ORDER BY ordered_at. Which one is first? The query has not defined it, so LAG(...) or LEAD(...) could refer to different tied rows depending on execution. If order_id uniquely identifies the sequence among tied timestamps, use ORDER BY ordered_at, order_id. Previous and next are meaningful only when the sequence itself is well defined.

05Ends of the sequence

Finding the first value in a sequence

Sometimes we do not want the immediately previous value; we want the first value in the entire partition. Use FIRST_VALUE(...). To show every order together with the customer’s first order amount:

Query
SELECT
    order_id,
    customer,
    ordered_at,
    order_amount,
    FIRST_VALUE(order_amount) OVER (
        PARTITION BY customer
        ORDER BY ordered_at, order_id
    ) AS first_order_amount
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;
Result
order_idcustomerorder_amountfirst_order_amount
1001Asha15001500
1003Asha22001500
1005Asha9001500
1002Ben32003200
1004Ben36003200
1006Ben50003200

Every Asha row carries first_order_amount = 1500 and every Ben row 3200. This makes comparisons against the beginning of a sequence easy, for example order_amount - FIRST_VALUE(order_amount) OVER (...) shows how far the current value has moved from the starting value.

LAST_VALUE: finding the last value

The natural counterpart is LAST_VALUE(...). To show every order with the customer’s latest order amount, you might write LAST_VALUE(order_amount) OVER (PARTITION BY customer ORDER BY ordered_at, order_id). This looks correct, but LAST_VALUE has an important window-frame trap.

With an ORDER BY, the default window frame in many SQL systems does not necessarily include every future row in the partition. So LAST_VALUE(order_amount) OVER (PARTITION BY customer ORDER BY ordered_at) can return the last value inside the current row’s frame, which may simply be the current row. For Asha, instead of every row showing 900, you may effectively get 1500 -> 1500, 2200 -> 2200, 900 -> 900. That is usually not what someone means by “last value in the customer’s complete sequence.”

The fix: define the full partition frame

Make the frame explicit:

Query
SELECT
    order_id,
    customer,
    order_amount,
    LAST_VALUE(order_amount) OVER (
        PARTITION BY customer
        ORDER BY ordered_at, order_id
        ROWS BETWEEN UNBOUNDED PRECEDING
                 AND UNBOUNDED FOLLOWING
    ) AS last_order_amount
FROM orders
ORDER BY
    customer,
    ordered_at,
    order_id;
Result
order_idcustomerorder_amountlast_order_amount
1001Asha1500900
1003Asha2200900
1005Asha900900
1002Ben32005000
1004Ben36005000
1006Ben50005000

The frame ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING means use the complete partition from its first row through its last row. This makes the intended meaning of LAST_VALUE explicit.

FIRST_VALUE vs LAG, LEAD vs LAST_VALUE

These answer different questions. For Asha’s third order (1500, 2200, 900 current), LAG(order_amount) returns 2200 (the immediately previous value) while FIRST_VALUE(order_amount) returns 1500 (the beginning of the partition). Similarly, LEAD looks one or more rows ahead, while LAST_VALUE looks at the end of the defined frame. Use LAG/FIRST_VALUE to compare with a nearby earlier row or the beginning of the sequence, and LEAD/LAST_VALUE to look ahead or at the end.

06Change detection

Detecting changes between events

A common event-data problem is return only rows where a value changed from the previous event. Suppose we have customer-status events:

Status Events
event_idcustomerevent_atstatus
1Asha09:00pending
2Asha09:05pending
3Asha09:15paid
4Asha09:20paid
5Asha10:00shipped

Compare each status with the previous status using LAG(status) OVER (PARTITION BY customer ORDER BY event_at, event_id). Conceptually the previous status runs NULL, pending, pending, paid, paid, so the changes (pending -> paid, paid -> shipped) are easy to recognize. Because the window value is calculated before the outer filtering step, a CTE keeps the pattern clear:

Query
WITH status_changes AS (
    SELECT
        event_id,
        customer,
        event_at,
        status,
        LAG(status) OVER (
            PARTITION BY customer
            ORDER BY event_at, event_id
        ) AS previous_status
    FROM status_events
)
SELECT
    event_id,
    customer,
    event_at,
    previous_status,
    status
FROM status_changes
WHERE previous_status IS DISTINCT FROM status
ORDER BY
    customer,
    event_at,
    event_id;

The exact null-safe comparison syntax varies across databases. In PostgreSQL, IS DISTINCT FROM is useful because it treats NULL as a comparable value. The broader pattern is: get the previous value with LAG, compare previous and current, and keep rows where they differ. This is useful for status transitions, price changes, configuration changes, account balance movements, state changes in event logs, and sensor transitions.

Finding the first and last event time

The same functions work with timestamps. FIRST_VALUE(ordered_at) and LAST_VALUE(ordered_at) (with the full UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING frame) let every row carry the first event time, current event time, and last event time. This helps answer how long after the first event this occurred, where in the customer’s lifecycle this event is, and how long the observed activity period is.

07Recognition cues

How to spot LAG, LEAD, and value problems

Think about LAG and LEAD when the requirement says previous order, next order, previous or next event, change from yesterday, difference from previous transaction, time since previous event, time until next event, status changed, or price increased or decreased. Think about FIRST_VALUE and LAST_VALUE when it says first value in the sequence, starting price, initial status, first event time, latest value, final status, or last event time.

Watch for these common mistakes:

  • PARTITION BY is missing and comparisons cross customers.
  • The ordering does not represent the real event sequence.
  • Tied timestamps have no deterministic tie-breaker.
  • A default value in LAG changes “no previous row” into a real business value.
  • LAST_VALUE uses a frame that ends at the current row.
  • Window-function results are filtered at the wrong query level.
08A practical mental model

Answer four questions for row-to-row comparisons

1 · What is the sequence?

PARTITION BY customer: each customer gets an independent sequence.

2 · What defines earlier and later?

ORDER BY ordered_at, order_id: chronological order, with the tie-breaker resolving timestamp ties.

3 · Which related row do I need?

Previous (LAG), next (LEAD), first (FIRST_VALUE), or last (LAST_VALUE).

4 · What comparison should I make?

Difference, elapsed time, status change (current <> previous), or distance from the starting point.

Key idea
Once these questions are clear, the window function becomes much easier to choose.
09Check your understanding

One more prediction

Consider Asha’s orders. For order 1005, what does LAG(order_amount) OVER (PARTITION BY customer ORDER BY ordered_at, order_id) return?

Asha's orders
order_idordered_atorder_amount
1001July 11500
1003July 102200
1005July 31900
ONE MORE PREDICTIONFor order 1005, what does LAG(order_amount) return?
Query
LAG(order_amount) OVER (
    PARTITION BY customer
    ORDER BY ordered_at, order_id
)

-- A: 1500
-- B: 2200
-- C: 900
-- D: NULL
Your prediction
10Summary

Summary

Analytical SQL becomes especially powerful when one row needs context from another row. Use LAG(value) OVER (...) to look backward and LEAD(value) OVER (...) to look forward. LAG(order_amount) OVER (PARTITION BY customer ORDER BY ordered_at, order_id) means: within this customer’s orders, return the amount from the immediately previous order. That previous value can then measure change in amount, percentage change, time since previous event, and status transitions. Use FIRST_VALUE(value) when the comparison should be against the beginning of the sequence, and LAST_VALUE(value) when it should be against the end, defining the frame explicitly (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) when the complete partition is required.

Key idea
Previous, next, first, and last have meaning only after the sequence has been defined correctly. That sequence usually comes from PARTITION BY (which entity the row belongs to), ORDER BY (what determines earlier and later), and a tie-breaker (how equal ordering values are resolved). A correct LAG with the wrong partition or ordering can produce perfectly valid SQL and still compare the wrong events. Define the sequence first, then ask which position in that sequence the business question needs.

Next, we will use these analytical patterns to solve deduplication and latest-row problems, including how to return exactly one current record per entity.

Go deeper

Ask Colearn about this pattern

Not sure why LAG is comparing the wrong rows, or why LAST_VALUE just echoes the current row? Ask about the concept, or paste a simplified version of your query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why is LAG returning another customer's value?

Without PARTITION BY, LAG follows the overall sequence, so the previous row before one customer's order can belong to a different customer.

Add PARTITION BY customer so each customer's sequence is independent: LAG(order_amount) OVER (PARTITION BY customer ORDER BY ordered_at, order_id). Define the sequence before asking for the previous or next row.

from the unit →
Why does LAST_VALUE just return the current row?

With an ORDER BY, the default window frame ends at the current row, so LAST_VALUE returns the last value inside that frame, which is often the current row itself.

Make the frame explicit: ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING uses the complete partition from its first row through its last row.

from the unit →
LAG or FIRST_VALUE: which do I use?

LAG compares with a nearby earlier row (the immediately previous value). FIRST_VALUE compares with the beginning of the partition (the starting value).

For Asha's third order, LAG returns 2200 (the previous order) while FIRST_VALUE returns 1500 (her first order).

from the unit →
How do I keep only rows where a value changed?

Get the previous value with LAG, then compare. Keep rows where previous and current differ. In PostgreSQL, WHERE previous_status IS DISTINCT FROM status treats NULL as a comparable value, so the first row is handled cleanly.

The null-safe comparison syntax varies across databases, but the pattern is: LAG, compare, keep the rows that differ.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P18Pattern · 11 minDeduplication & latest rowUse ROW_NUMBER with a deterministic tie-breaker to keep exactly one row per entity.P16Pattern · 11 minRunning totals & moving averagesThe window-frame idea behind the LAST_VALUE full-partition frame.P15Pattern · 10 minWindow functions & rankingThe OVER and PARTITION BY foundation that defines the sequence.
Up next · Pattern 18
Deduplication & latest row

Next, we use these analytical patterns to solve deduplication and latest-row problems: returning exactly one current record per entity.

Continue to P18 →Browse all patterns