Learn how to use SQL LEAD and LAG functions to access previous and next row values without self-joins. These powerful window functions make it easy to compare records, calculate changes, and analyze trends across time or transactions.
In this tutorial, you’ll explore practical SQL LEAD LAG examples and common interview questions to master how these functions look ahead and behind within your data.
In simple terms:
LEAD()
lets you grab the next row's valueLAG()
grabs the previous oneThey work inside a window, meaning they operate over a group of rows — without collapsing them like GROUP BY
.
OrderID | CustomerID | OrderDate | Amount |
---|---|---|---|
101 | A1 | 2024-01-01 | 100 |
102 | A1 | 2024-01-03 | 120 |
103 | A1 | 2024-01-05 | 90 |
If we write:
SELECT OrderID, Amount,
LAG(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS PrevAmount
FROM my_table;
We’ll get:
OrderID | Amount | PrevAmount |
---|---|---|
101 | 100 | NULL |
102 | 120 | 100 |
103 | 90 | 120 |
Magical? Close. It’s just window functions being awesome.
Use these functions any time you want to:
Many SQL interview questions test whether you recognize when to use LEAD or LAG. These functions appear in problems asking you to compare previous and next row values or detect changes between records. If you spot patterns like these, it’s probably a LEAD LAG interview question in SQL:
These questions test your ability to think in sequences, recognize PARTITION BY
logic,
and understand how LAG()
and LEAD()
look backward and forward within a dataset.
-- Get previous order amount
LAG(TotalAmount) OVER (
PARTITION BY CustomerID
ORDER BY OrderDate
) AS PrevAmount
-- Get next delivery date
LEAD(DeliveryDate) OVER (
PARTITION BY ProductID
ORDER BY OrderDate
) AS NextDelivery
By default, LAG()
and LEAD()
return the value just one row away.
But you can also “peek” two or more rows forward or backward by adding an offset argument.
SELECT CustomerID, OrderDate, Amount,
LAG(Amount, 2) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS TwoOrdersAgo
FROM Orders;
This retrieves the value from two rows earlier within each customer's order history.
Similarly, LEAD(Amount, 3)
would look three rows ahead instead of one.
Offsets are especially helpful when analyzing trends, such as comparing a customer's spending to two orders ago or spotting repeating patterns in sensor data.
By default, LAG()
and LEAD()
return NULL
when there’s no previous or next row. But what if you want to fill that in with something useful — like 0
, 'N/A'
, or "no change"
?
You’ve got two main options:
SQL lets you set a default value right inside the function:
LAG(Salary, 1, 0)
This means:
Same applies to LEAD()
:
LEAD(DeliveryDate, 1, '2999-12-31')
This gives you even more flexibility — especially when chaining or nesting:
COALESCE(LAG(Amount) OVER (...), 0)
Or if you're doing something dynamic like this:
COALESCE(
LAG(Salary) OVER (PARTITION BY Department ORDER BY HireDate),
Salary -- fallback to current salary if none before
)
Situation | Use This |
---|---|
Simple fallback | LAG(x, 1, fallback) |
You want clarity + readability | LAG(x, 1, fallback) |
Fallback depends on logic | COALESCE(LAG(...), x) |
You're chaining or nesting | COALESCE(...) |
Both are correct and efficient. Pick based on readability and use case.
Even Xorthax got tripped up once or twice. Here are mistakes to dodge:
Forgetting ORDER BY
LAG()
and LEAD()
require ORDER BY
inside the OVER()
clause. Without it? Undefined chaos.Misunderstanding NULLs
LAG()
and last row of LEAD()
will return NULL by default. That’s expected — nothing to look at before or after!Skipping PARTITION BY
when needed
Wrong offset
LAG(Amount, 2) → 2 rows behind
LEAD(Date, 3) → 3 rows ahead
Unlike SUM()
or AVG()
, these functions don’t use ROWS BETWEEN
. Why? Because they just fetch a specific row ahead or behind — not a range.
Sometimes, you’ll want to calculate with LAG/LEAD, then filter on it. That’s where CTEs come in handy.
WITH Ordered AS (
SELECT
CustomerID,
OrderID,
TotalAmount,
LAG(TotalAmount) OVER (PARTITION BY CustomerID ORDER BY OrderDate) AS PrevAmount
FROM Orders
)
SELECT *
FROM Ordered
WHERE TotalAmount < PrevAmount;
This shows all orders where the customer spent less than the time before.
You’re not limited to one! This is totally valid:
SELECT
OrderID,
TotalAmount,
LAG(TotalAmount) OVER (...) AS Prev,
LEAD(TotalAmount) OVER (...) AS Next
You can also combine with ROW_NUMBER()
to get order positions or track gaps.
Let’s be clear:
Feature | Window Function | Aggregate (GROUP BY) |
---|---|---|
Keeps every row | ✅ | ❌ |
Can look ahead/behind | ✅ (LEAD() , LAG() ) |
❌ |
Collapses to one row/group | ❌ | ✅ |
ROW_NUMBER()
temporarily to see your row order.Use COALESCE()
or the optional argument to fill NULLs if needed:
COALESCE(LAG(x), 0)
or
LAG(Salary, 1, 0)
You might also like: FIRST_VALUE and Related Functions, ROW_NUMBER and Ranking Functions, Trends with SUM + ROWS BETWEEN