LAG() — looking at the previous row

LAG() reaches backward from the current row, based on the ordering defined inside OVER(), and returns a value from that earlier row.

lag-basic.sql
SELECT order_date, total,
  LAG(total) OVER (ORDER BY order_date) AS prev_total
FROM daily_sales;
order_datetotalprev_total
Jan 1500NULL
Jan 2620500
Jan 3580620

Every row gets the previous row's total attached to it — except the very first row, which has nothing before it (more on that in the default value section).

LEAD() — looking at the next row

LEAD() is the mirror image of LAG() — it reaches forward instead of backward.

lead-basic.sql
SELECT order_date, total,
  LEAD(total) OVER (ORDER BY order_date) AS next_total
FROM daily_sales;
order_datetotalnext_total
Jan 1500620
Jan 2620580
Jan 3580NULL

Same mechanism, opposite direction — the last row has nothing after it, so its next_total is NULL. For the wider family of window functions these belong to, see Window Functions Explained Simply.

The offset argument

Both functions accept a second argument: how many rows back (for LAG) or forward (for LEAD) to reach. It defaults to 1 if omitted.

lag-offset.sql
SELECT order_date, total,
  LAG(total, 1) OVER (ORDER BY order_date) AS prev_1_day,
  LAG(total, 7) OVER (ORDER BY order_date) AS prev_7_days
FROM daily_sales;

LAG(total, 7) is the standard way to express "compare to the same day last week" once the data is ordered daily — no need for a self-join with a computed date offset in the join condition.

The default value argument

A third argument sets what to return instead of NULL when there's no row to look at — useful when a downstream calculation would otherwise need to handle NULL as a special case.

lag-default.sql
SELECT order_date, total,
  LAG(total, 1, 0) OVER (ORDER BY order_date) AS prev_total
FROM daily_sales;
-- first row's prev_total is 0 instead of NULL
Be deliberate about the default. Using 0 as a stand-in for "no previous day" is fine for a change calculation, but it can be misleading if 0 is also a legitimate value the column could actually hold — in that case, leaving the default as NULL and handling it explicitly downstream is safer. See NULL Handling in SQL for the underlying reasoning.

Combining LAG/LEAD with PARTITION BY

Without PARTITION BY, LAG() and LEAD() operate across the entire ordered result set — which means a row can reach into a different customer's or category's data at a boundary. Adding PARTITION BY resets the look-back at the start of every group.

lag-with-partition.sql
SELECT customer_id, order_date, total,
  LAG(total) OVER (
    PARTITION BY customer_id
    ORDER BY order_date
  ) AS prev_order_total
FROM orders;
customer_idorder_datetotalprev_order_total
101Jan 190NULL
101Jan 96090
102Jan 3150NULL

Customer 102's first order correctly shows NULL instead of reaching back into customer 101's last order — PARTITION BY is what keeps the comparison confined to each customer's own order history. This is the same partitioning mechanism covered in full in PARTITION BY Explained in SQL.

Example: month-over-month change

A standard business reporting pattern — compute the change and percentage change from the previous period in a single query:

month-over-month.sql
SELECT month, revenue,
  revenue - LAG(revenue) OVER (ORDER BY month) AS change,
  ROUND(
    100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
    / LAG(revenue) OVER (ORDER BY month), 1
  ) AS pct_change
FROM monthly_revenue;
monthrevenuechangepct_change
Jan10,000NULLNULL
Feb11,5001,50015.0
Mar10,800-700-6.1

Before window functions, this required a self-join keyed on a computed "previous month" value — fragile the moment a month is missing entirely. LAG() just uses whatever row actually comes before it in the defined order, regardless of gaps.

Example: flagging a gap in sequential data

LAG() is also the standard way to detect missing entries in a sequence, such as a skipped invoice number or a missing day in a daily log:

detect-gaps.sql
SELECT invoice_id,
  invoice_id - LAG(invoice_id) OVER (ORDER BY invoice_id) AS gap
FROM invoices;
-- any gap other than 1 marks a missing or deleted invoice number

Why LAG/LEAD beats a self-join

Before window functions were widely available, comparing a row to its predecessor meant a self-join: alias the table twice, and join row B to row A where B's key is one greater than A's, or where B's date is the closest date after A's. That join condition is exactly where gaps and duplicate values cause trouble — a missing invoice number breaks a +1 join condition outright, and duplicate timestamps make "the next row" ambiguous. LAG() and LEAD() sidestep the join condition entirely by working directly against the row order established by ORDER BY. See the self-join pitfalls directly in SQL Tricky Interview Questions With Answers.

Key takeaways

  • LAG() reaches backward, LEAD() reaches forward, both relative to ORDER BY inside OVER().
  • The second argument is the offset (default 1); the third is the default value (default NULL).
  • PARTITION BY resets LAG/LEAD at the start of each group, preventing cross-group leakage.
  • Month-over-month and gap-detection queries are the two most common real-world LAG patterns.
  • LAG/LEAD replace a self-join with no join condition to get wrong on gaps or duplicates.

Frequently asked questions

What is the difference between LAG and LEAD in SQL?

LAG() looks backward and returns a value from an earlier row relative to the current one, based on the ORDER BY inside OVER(). LEAD() looks forward and returns a value from a later row. Both default to looking exactly one row away unless a different offset is specified.

What does the second argument to LAG or LEAD do?

The second argument is the offset — how many rows back or forward to look. LAG(total, 2) looks two rows back instead of one. Omitting it defaults to an offset of 1.

What happens when LAG or LEAD has no row to look at?

At the boundaries of the result set — the first row for LAG, the last row for LEAD — there is no previous or next row to return, so the function returns NULL by default. A third argument can be supplied to return a custom default value instead of NULL.

How do LAG and LEAD behave with PARTITION BY?

Adding PARTITION BY resets LAG and LEAD at the start of every partition, so the first row of each partition returns NULL (or the default) for LAG instead of reaching back into the previous partition's data, keeping comparisons confined to their own group.

Why use LAG instead of a self-join to compare a row to the previous one?

A self-join requires matching each row to its predecessor through a join condition, which gets error-prone with gaps or ties in the ordering column, and typically costs more to execute. LAG expresses the same intent directly against the row order, with no join condition to get wrong.

Last updated: July 27, 2026  ·  Written by the Sqlism Team