Ever wish SQL could just tell you what came before* or *after* a row — without doing a self-join gymnastics routine?*
Enter LEAD()
and LAG()
— two of Xorthax's favorite tools for peeking into adjacent rows while keeping your query elegant.
In simple terms:
They 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:
LAG(Amount) OVER (PARTITION BY CustomerID ORDER BY OrderDate)
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:
When a question includes any of the following, LEAD or LAG should be top of mind:
In your SQL muscle memory, that’s a LAG/LEAD flag.
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
-- 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 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.
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 optional argument to fill NULLs if needed:
COALESCE(LAG(x), 0)
or
LAG(Salary, 1, 0)
(You might also like: FIRST_VALUE, ROW_NUMBER, Trends with SUM + ROWS BETWEEN)