All 40 lessons
Lesson 28 of 40 · Window Functions
SQL LAG and LEAD
By Skillancy Editorial Team
Every example runs on the ShopKart dataset, already loaded in the free SQL Compiler. Click Try it in the SQL Compiler under a query to open it there, then press Run.
LAG reads a value from the previous row and LEAD reads one from the next row, within a window you define. They make "compared with last time" questions easy: month-over-month growth, days since a customer's previous order, or the change in a price.
Syntax
LAG(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)
LEAD(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)offset(optional, default 1) is how many rows to look back or ahead.default(optional) replaces NULL when there's no such row.
Examples
1. Previous and next order dates
SELECT order_id,
order_date,
LAG(order_date) OVER (ORDER BY order_date) AS previous_order,
LEAD(order_date) OVER (ORDER BY order_date) AS next_order
FROM orders
ORDER BY order_date
LIMIT 5;Try it in the SQL CompilerResult:
| order_id | order_date | previous_order | next_order |
|---|---|---|---|
| 1001 | 2025-01-05 | NULL | 2025-01-18 |
| 1002 | 2025-01-18 | 2025-01-05 | 2025-02-02 |
| 1003 | 2025-02-02 | 2025-01-18 | 2025-02-20 |
| 1004 | 2025-02-20 | 2025-02-02 | 2025-03-11 |
| 1005 | 2025-03-11 | 2025-02-20 | 2025-03-29 |
(5 rows)
The first row has no previous order, so LAG returns NULL.
2. Days between a customer's orders
Add PARTITION BY customer_id so the comparison stays within each customer:
SELECT customer_id,
order_id,
order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_date,
order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS days_since_prev
FROM orders
WHERE customer_id IN (1, 3)
ORDER BY customer_id, order_date;Try it in the SQL CompilerResult:
| customer_id | order_id | order_date | prev_order_date | days_since_prev |
|---|---|---|---|---|
| 1 | 1001 | 2025-01-05 | NULL | NULL |
| 1 | 1004 | 2025-02-20 | 2025-01-05 | 46 |
| 1 | 1011 | 2025-06-30 | 2025-02-20 | 130 |
| 1 | 1019 | 2025-11-15 | 2025-06-30 | 138 |
| 3 | 1003 | 2025-02-02 | NULL | NULL |
| 3 | 1009 | 2025-05-21 | 2025-02-02 | 108 |
| 3 | 1021 | 2025-12-20 | 2025-05-21 | 213 |
(7 rows)
3. Average repeat-purchase gap
Wrap the previous query in a CTE to summarise it:
WITH gaps AS (
SELECT customer_id,
order_date - LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS days_since_prev
FROM orders
)
SELECT ROUND(AVG(days_since_prev), 1) AS avg_days_between_orders,
COUNT(days_since_prev) AS repeat_orders
FROM gaps;Try it in the SQL CompilerResult:
| avg_days_between_orders | repeat_orders |
|---|---|
| 162.7 | 13 |
(1 row)
4. Month-over-month growth
The classic analyst question. Aggregate by month first, then compare each month with the one before:
WITH monthly AS (
SELECT DATE_TRUNC('month', o.order_date)::date AS month,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_pct / 100)) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
WHERE o.status = 'Delivered'
AND o.order_date < '2025-08-01'
GROUP BY 1
)
SELECT month,
ROUND(revenue, 0) AS revenue,
ROUND(LAG(revenue) OVER (ORDER BY month), 0) AS prev_month,
ROUND((revenue - LAG(revenue) OVER (ORDER BY month))
/ LAG(revenue) OVER (ORDER BY month) * 100, 1) AS mom_growth_pct
FROM monthly
ORDER BY month;Try it in the SQL CompilerResult:
| month | revenue | prev_month | mom_growth_pct |
|---|---|---|---|
| 2025-01-01 | 32271 | NULL | NULL |
| 2025-02-01 | 28839 | 32271 | -10.6 |
| 2025-03-01 | 3492 | 28839 | -87.9 |
| 2025-04-01 | 2988 | 3492 | -14.4 |
| 2025-05-01 | 3685 | 2988 | 23.3 |
| 2025-06-01 | 10746 | 3685 | 191.6 |
(6 rows)
ShopKart is small, so growth jumps around. On real data this query feeds a growth chart directly.
5. A default value instead of NULL
SELECT order_id,
order_date,
LAG(order_id, 1, 0) OVER (ORDER BY order_date) AS previous_order_id
FROM orders
ORDER BY order_date
LIMIT 3;Try it in the SQL CompilerResult:
| order_id | order_date | previous_order_id |
|---|---|---|
| 1001 | 2025-01-05 | 0 |
| 1002 | 2025-01-18 | 1001 |
| 1003 | 2025-02-02 | 1002 |
(3 rows)
6. FIRST_VALUE: compare with the first row
FIRST_VALUE returns the first value in the window. Each customer's first order date next to every order:
SELECT customer_id,
order_id,
order_date,
FIRST_VALUE(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS first_order_date
FROM orders
WHERE customer_id IN (1, 4)
ORDER BY customer_id, order_date;Try it in the SQL CompilerResult:
| customer_id | order_id | order_date | first_order_date |
|---|---|---|---|
| 1 | 1001 | 2025-01-05 | 2025-01-05 |
| 1 | 1004 | 2025-02-20 | 2025-01-05 |
| 1 | 1011 | 2025-06-30 | 2025-01-05 |
| 1 | 1019 | 2025-11-15 | 2025-01-05 |
| 4 | 1005 | 2025-03-11 | 2025-03-11 |
| 4 | 1013 | 2025-08-02 | 2025-03-11 |
| 4 | 1023 | 2026-01-26 | 2025-03-11 |
(7 rows)
LAST_VALUE needs a frame clause to work as expected; that's covered in the next lesson.
Common mistakes
- Missing PARTITION BY. Without it, customer A's first order is compared with customer B's last order.
- Gaps in the data.
LAGlooks at the previous row, not the previous month. If a month has no orders, it compares with two months ago. Build a complete month list first (see CROSS JOIN). - Dividing by a zero previous value. Wrap the denominator in
NULLIF(..., 0).
Practice question
For each product, list its order lines in date order with the quantity ordered on the previous line for the same product.
Show solution
SELECT oi.product_id,
o.order_date,
oi.quantity,
LAG(oi.quantity) OVER (PARTITION BY oi.product_id ORDER BY o.order_date) AS prev_quantity
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
WHERE oi.product_id IN (101, 102)
ORDER BY oi.product_id, o.order_date;Try it in the SQL CompilerResult:
| product_id | order_date | quantity | prev_quantity |
|---|---|---|---|
| 101 | 2025-01-05 | 1 | NULL |
| 101 | 2025-07-14 | 1 | 1 |
| 101 | 2025-10-28 | 1 | 1 |
| 102 | 2025-01-05 | 2 | NULL |
| 102 | 2025-05-03 | 1 | 2 |
| 102 | 2025-10-28 | 1 | 1 |
| 102 | 2025-12-20 | 2 | 1 |
(7 rows)
Key takeaways
LAGlooks back andLEADlooks ahead within an ordered window.PARTITION BYkeeps comparisons inside each customer, product or group.- Month-over-month growth = aggregate by month, then
LAG. FIRST_VALUEreturns the first value in the window.
Frequently asked questions
What does LAG do in SQL?
LAG(column) returns the value of that column from the previous row in the window's order. LEAD returns the value from the next row. Both return NULL when there is no such row.
How do I calculate month-over-month growth in SQL?
Aggregate to one row per month in a CTE, then compute (value - LAG(value) OVER (ORDER BY month)) / LAG(value) OVER (ORDER BY month).
Can LAG look back more than one row?
Yes. LAG(column, 2) looks two rows back. A third argument sets a default instead of NULL, for example LAG(revenue, 1, 0).
Keep learning SQL
One email a week with SQL and analytics guides, new practice questions and cohort dates. No spam, unsubscribe anytime.
Prefer a one-page summary? Download the free SQL cheat sheet (PDF)