SQL LAG() and LEAD(): Compare a Row With the Previous or Next
LAG() returns a value from an earlier row and LEAD() returns a value from a later row, in the order you define. They let you compare each row with the one before or after it, for example this month's revenue against last month's, without joining the table to itself.
What Are LAG() and LEAD()?
Start with revenue per month for the second half of 2024, then add two columns: the previous month's revenue with LAG and the next month's with LEAD.
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2024-07-01'
GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS previous_month,
LEAD(revenue) OVER (ORDER BY month) AS next_month
FROM monthly
ORDER BY month;
▶ Run this query in the SQL Playground
| month | revenue | previous_month | next_month |
|---|---|---|---|
| 2024-07 | 10296.67 | NULL | 25429.09 |
| 2024-08 | 25429.09 | 10296.67 | 4319.46 |
| 2024-09 | 4319.46 | 25429.09 | 7996.72 |
| 2024-10 | 7996.72 | 4319.46 | 17380.72 |
| 2024-11 | 17380.72 | 7996.72 | 10621.36 |
| 2024-12 | 10621.36 | 17380.72 | NULL |
Each row now carries its neighbours' values. The first month has no previous row, so previous_month is NULL; the last month has no next row, so next_month is NULL.
strftime. The Playground runs SQLite, where strftime('%Y-%m', date) extracts the year and month. In PostgreSQL use TO_CHAR(order_date, 'YYYY-MM'), in MySQL DATE_FORMAT(order_date, '%Y-%m') and in SQL Server FORMAT(order_date, 'yyyy-MM'). LAG and LEAD themselves are written the same way in all of them. See SQL date functions.Syntax
LAG(column [, offset [, default]]) OVER ([PARTITION BY group_column] ORDER BY sort_column)
LEAD(column [, offset [, default]]) OVER ([PARTITION BY group_column] ORDER BY sort_column)
| Argument | Meaning | If omitted |
|---|---|---|
| column | The value to fetch from the other row | Required |
| offset | How many rows back (LAG) or forward (LEAD) | 1 |
| default | What to return when there is no such row | NULL |
The ORDER BY inside OVER defines what "previous" and "next" mean. LAG and LEAD are window functions, available in PostgreSQL, SQL Server, Oracle, MySQL 8.0+ and SQLite 3.25+.
Month-over-Month Change
Once the previous value is on the same row, the change is ordinary arithmetic. This calculates the difference and the percentage change from the month before:
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2024-07-01'
GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
ROUND(revenue - LAG(revenue) OVER (ORDER BY month), 2) AS change,
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month), 1) AS change_pct
FROM monthly
ORDER BY month;
▶ Run this query in the SQL Playground
| month | revenue | change | change_pct |
|---|---|---|---|
| 2024-07 | 10296.67 | NULL | NULL |
| 2024-08 | 25429.09 | 15132.42 | 147 |
| 2024-09 | 4319.46 | -21109.63 | -83 |
| 2024-10 | 7996.72 | 3677.26 | 85.1 |
| 2024-11 | 17380.72 | 9384 | 117.3 |
| 2024-12 | 10621.36 | -6759.36 | -38.9 |
The first row shows NULL for both calculations, because any arithmetic with NULL gives NULL. That is the correct answer: there is no earlier month to compare with.
Previous Row per Customer
With PARTITION BY, "previous" means the previous row for the same customer. This shows each order next to that customer's previous order date and the number of days between them:
SELECT user_id, order_date,
LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS previous_order,
CAST(julianday(order_date)
- julianday(LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id)) AS INTEGER) AS days_since_previous
FROM orders
WHERE user_id IN (37, 77)
ORDER BY user_id, order_date, id;
▶ Run this query in the SQL Playground
| user_id | order_date | previous_order | days_since_previous |
|---|---|---|---|
| 37 | 2023-01-03 | NULL | NULL |
| 37 | 2023-02-17 | 2023-01-03 | 45 |
| 37 | 2023-05-04 | 2023-02-17 | 76 |
| 37 | 2023-06-13 | 2023-05-04 | 40 |
| 37 | 2024-01-06 | 2023-06-13 | 207 |
| 77 | 2022-12-15 | NULL | NULL |
| 77 | 2023-03-18 | 2022-12-15 | 93 |
| 77 | 2023-06-03 | 2023-03-18 | 77 |
| 77 | 2024-01-11 | 2023-06-03 | 222 |
| 77 | 2024-08-16 | 2024-01-11 | 218 |
A customer's first order has no previous order, so both extra columns are NULL, and the comparison never reaches across to another customer. (julianday is SQLite's way of subtracting dates; other databases use DATEDIFF or plain subtraction.)
Offset and Default Value
The second argument reaches further back, and the third replaces the NULL when no row exists. Here LAG(revenue, 2, 0) looks two months back and returns 0 when there is nothing there:
WITH monthly AS (
SELECT strftime('%Y-%m', order_date) AS month,
ROUND(SUM(total), 2) AS revenue
FROM orders
WHERE order_date >= '2024-07-01'
GROUP BY strftime('%Y-%m', order_date)
)
SELECT month, revenue,
LAG(revenue, 2, 0) OVER (ORDER BY month) AS two_months_ago
FROM monthly
ORDER BY month;
▶ Run this query in the SQL Playground
| month | revenue | two_months_ago |
|---|---|---|
| 2024-07 | 10296.67 | 0 |
| 2024-08 | 25429.09 | 0 |
| 2024-09 | 4319.46 | 10296.67 |
| 2024-10 | 7996.72 | 25429.09 |
| 2024-11 | 17380.72 | 4319.46 |
| 2024-12 | 10621.36 | 7996.72 |
With monthly data, an offset of 12 compares each month with the same month a year earlier.
Common LAG and LEAD Mistakes
Three errors and how to avoid them.
1. Forgetting PARTITION BY
LAG(order_date) OVER (ORDER BY order_date)
LAG(order_date) OVER (
PARTITION BY user_id
ORDER BY order_date
)
Without PARTITION BY the previous row is simply the previous row in the whole table, which usually belongs to someone else.
2. Using a default that distorts the result
revenue - LAG(revenue, 1, 0) OVER (ORDER BY month)
revenue - LAG(revenue) OVER (ORDER BY month)
A default of 0 makes the first row look as if revenue grew from nothing. For a change calculation, NULL ("no comparison available") is the honest value.
3. Filtering before the window is calculated
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev
FROM monthly
WHERE month = '2024-12';
WITH with_prev AS (
SELECT month, revenue,
LAG(revenue) OVER (ORDER BY month) AS prev
FROM monthly
)
SELECT month, revenue, prev
FROM with_prev
WHERE month = '2024-12';
WHERE removes rows before window functions run. If only December is left, there is no previous row and LAG returns NULL. Compute LAG in a CTE, then filter.
Practice LAG & LEAD Queries
4 exercises on the real Playground tables.
Write each query yourself first. Every solution has been run against the Playground database.
-
1Easy Show monthly order counts for 2024 with the previous month's count beside each. Table:
ordersShow solution
WITH monthly AS ( SELECT strftime('%Y-%m', order_date) AS month, COUNT(*) AS orders FROM orders WHERE order_date >= '2024-01-01' GROUP BY strftime('%Y-%m', order_date) ) SELECT month, orders, LAG(orders) OVER (ORDER BY month) AS previous_month FROM monthly ORDER BY month;▶ Run this query in the SQL Playground
One row per month, 12 in total; the first has NULL because nothing comes before it.
-
2Medium For customers 37, 54 and 77, show each order with the date of that customer's next order. Table:
ordersShow solution
SELECT user_id, order_date, LEAD(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS next_order FROM orders WHERE user_id IN (37, 54, 77) ORDER BY user_id, order_date, id;▶ Run this query in the SQL Playground
LEAD looks forward. Each customer's last order has NULL in next_order.
-
3Medium Show yearly revenue and the change from the previous year. Table:
ordersShow solution
WITH yearly AS ( SELECT strftime('%Y', order_date) AS year, ROUND(SUM(total), 2) AS revenue FROM orders GROUP BY strftime('%Y', order_date) ) SELECT year, revenue, ROUND(revenue - LAG(revenue) OVER (ORDER BY year), 2) AS change FROM yearly ORDER BY year;▶ Run this query in the SQL Playground
The same pattern as month-over-month, grouped by year instead.
-
4Challenging Find the five longest gaps, in days, between two consecutive orders of the same customer. Table:
ordersShow solution
WITH gaps AS ( SELECT user_id, order_date, LAG(order_date) OVER (PARTITION BY user_id ORDER BY order_date, id) AS previous_order FROM orders ) SELECT user_id, previous_order, order_date, CAST(julianday(order_date) - julianday(previous_order) AS INTEGER) AS gap_days FROM gaps WHERE previous_order IS NOT NULL ORDER BY gap_days DESC, user_id LIMIT 5;▶ Run this query in the SQL Playground
LAG is calculated in the CTE, then the outer query removes first orders and sorts by the size of the gap.
📝 Quiz — LAG & LEAD
Test your understanding · 0/5 answered
What does LAG(sales) OVER (ORDER BY month) return?
LAG looks one row back in the window order, so it returns the previous month's sales.
What does LAG return for the first row of a partition by default?
There is no earlier row, so the result is NULL unless you supply a default as the third argument.
What does the 2 mean in LEAD(price, 2)?
The second argument is the offset: how many rows forward (LEAD) or back (LAG) to look.
You want each customer's previous order date. What must the OVER clause contain?
PARTITION BY user_id keeps the comparison inside one customer; ORDER BY order_date defines which row is previous.
Which query design do LAG and LEAD most often replace?
Before window functions, comparing a row with the previous one required joining the table to itself.
LAG & LEAD Interview Questions
Q1. What is the difference between LAG() and LEAD()?
LAG() reads a value from a row before the current one and LEAD() from a row after it, both in the order given by the OVER clause. LAG(x) with ascending order returns the same values as LEAD(x) with descending order.
Q2. How do you calculate month-over-month growth in SQL?
Aggregate to one row per month, use LAG(revenue) OVER (ORDER BY month) to bring the previous month onto the row, then compute (revenue - previous) / previous.
Q3. How would you find customers whose two consecutive orders were more than 30 days apart?
Use LAG(order_date) OVER (PARTITION BY customer ORDER BY order_date) in a CTE, subtract it from the current order date, and filter the outer query for gaps above 30 days.
Frequently Asked Questions
What is the LAG function in SQL?
LAG() is a window function that returns a value from a previous row in the result, based on the ORDER BY in its OVER clause. It is commonly used to compare a row with the one before it.
What is the difference between LAG and LEAD?
LAG() looks backwards to an earlier row and LEAD() looks forwards to a later row. They take the same arguments: the column, an optional offset and an optional default value.
Why does LAG return NULL?
LAG returns NULL when there is no earlier row, which happens on the first row of the result or of each partition. Pass a third argument to return a different value instead.
Do LAG and LEAD work in MySQL?
Yes, from MySQL 8.0. They are also supported in PostgreSQL, SQL Server, Oracle and SQLite 3.25 or later.