SQL LAG(), LEAD(), FIRST_VALUE(), LAST_VALUE() as Window Functions
Some of the most valuable questions in data analysis involve comparing a row to its neighbors: How do this month's sales compare to last month's? What was the first order a customer ever placed? What is the difference between today's stock price and tomorrow's closing price? Answering these questions in SQL used to require painful self-joins and correlated subqueries. With the SQL LAG LEAD family of window functions, these comparisons become elegant one-liners.
This guide covers four navigation window functions: LAG(), LEAD(), FIRST_VALUE(), and LAST_VALUE(). You will learn how each one works, how to use them for period-over-period comparisons, and how the ROWS BETWEEN frame specification controls exactly which rows these functions can see. Every concept includes practical examples with clear outputs.
Prerequisites
You should be comfortable with SQL window function basics, including the OVER() clause, PARTITION BY, and ORDER BY inside OVER(). All four functions in this guide are dedicated window functions. They can only be used with the OVER() clause and have no standalone aggregate equivalent.
The Sample Data
We will use two tables throughout this guide.
monthly_revenue table:
| id | product | month | revenue |
|---|---|---|---|
| 1 | Widget A | 2024-01-01 | 12000 |
| 2 | Widget A | 2024-02-01 | 14500 |
| 3 | Widget A | 2024-03-01 | 13200 |
| 4 | Widget A | 2024-04-01 | 15800 |
| 5 | Widget A | 2024-05-01 | 16100 |
| 6 | Widget B | 2024-01-01 | 8000 |
| 7 | Widget B | 2024-02-01 | 7200 |
| 8 | Widget B | 2024-03-01 | 9100 |
| 9 | Widget B | 2024-04-01 | 8800 |
| 10 | Widget B | 2024-05-01 | 9500 |
CREATE TABLE monthly_revenue (
id INT PRIMARY KEY,
product VARCHAR(50) NOT NULL,
month DATE NOT NULL,
revenue DECIMAL(12,2) NOT NULL
);
INSERT INTO monthly_revenue (id, product, month, revenue) VALUES
(1, 'Widget A', '2024-01-01', 12000),
(2, 'Widget A', '2024-02-01', 14500),
(3, 'Widget A', '2024-03-01', 13200),
(4, 'Widget A', '2024-04-01', 15800),
(5, 'Widget A', '2024-05-01', 16100),
(6, 'Widget B', '2024-01-01', 8000),
(7, 'Widget B', '2024-02-01', 7200),
(8, 'Widget B', '2024-03-01', 9100),
(9, 'Widget B', '2024-04-01', 8800),
(10, 'Widget B', '2024-05-01', 9500);
daily_prices table:
| id | ticker | trade_date | close_price |
|---|---|---|---|
| 1 | ACME | 2024-01-15 | 142.50 |
| 2 | ACME | 2024-01-16 | 145.20 |
| 3 | ACME | 2024-01-17 | 143.80 |
| 4 | ACME | 2024-01-18 | 148.00 |
| 5 | ACME | 2024-01-19 | 147.30 |
CREATE TABLE daily_prices (
id INT PRIMARY KEY,
ticker VARCHAR(10) NOT NULL,
trade_date DATE NOT NULL,
close_price DECIMAL(10,2) NOT NULL
);
INSERT INTO daily_prices (id, ticker, trade_date, close_price) VALUES
(1, 'ACME', '2024-01-15', 142.50),
(2, 'ACME', '2024-01-16', 145.20),
(3, 'ACME', '2024-01-17', 143.80),
(4, 'ACME', '2024-01-18', 148.00),
(5, 'ACME', '2024-01-19', 147.30);
LAG(): Accessing Previous Row Values
LAG() retrieves a value from a previous row relative to the current row, based on the order defined in OVER(). Think of it as "looking backward" in your sorted result set.
Syntax
LAG(column, offset, default_value) OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC]
)
| Parameter | Required | Description |
|---|---|---|
column | Yes | The column whose value you want from the previous row |
offset | No | How many rows back to look. Defaults to 1 |
default_value | No | Value returned when there is no previous row. Defaults to NULL |
ORDER BY inside OVER() is required for LAG(). Without a defined order, "previous row" has no meaning, and the database would return unpredictable results.
Basic Example: Previous Month's Revenue
SELECT
product,
month,
revenue,
LAG(revenue) OVER (
PARTITION BY product
ORDER BY month
) AS prev_month_revenue
FROM monthly_revenue;
Output:
| product | month | revenue | prev_month_revenue |
|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | NULL |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 |
| Widget B | 2024-01-01 | 8000.00 | NULL |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 |
| Widget B | 2024-03-01 | 9100.00 | 7200.00 |
| Widget B | 2024-04-01 | 8800.00 | 9100.00 |
| Widget B | 2024-05-01 | 9500.00 | 8800.00 |
The first row of each product partition returns NULL because there is no previous row to look at. Every other row successfully retrieves the revenue from one row back.
Specifying an Offset
By default, LAG() looks back 1 row. You can change this by providing a second argument:
SELECT
product,
month,
revenue,
LAG(revenue, 1) OVER (PARTITION BY product ORDER BY month) AS prev_1_month,
LAG(revenue, 2) OVER (PARTITION BY product ORDER BY month) AS prev_2_months
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | prev_1_month | prev_2_months |
|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | NULL | NULL |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | NULL |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 | 12000.00 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 | 14500.00 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 | 13200.00 |
LAG(revenue, 2) looks back 2 rows. January and February return NULL because there are fewer than 2 preceding rows available.
Providing a Default Value
To avoid NULL on the first rows, pass a third argument as the default:
SELECT
product,
month,
revenue,
LAG(revenue, 1, 0) OVER (
PARTITION BY product
ORDER BY month
) AS prev_month_revenue
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | prev_month_revenue |
|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 0.00 |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 |
January now shows 0 instead of NULL. Choose a default that makes sense for your use case. In some scenarios, keeping NULL is actually preferable since it clearly indicates "no data available."
LEAD(): Accessing Next Row Values
LEAD() is the mirror image of LAG(). It retrieves a value from a subsequent row relative to the current row. Think of it as "looking forward."
Syntax
LEAD(column, offset, default_value) OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC]
)
The parameters are identical to LAG(), but instead of looking backward, LEAD() looks forward.
Basic Example: Next Month's Revenue
SELECT
product,
month,
revenue,
LEAD(revenue) OVER (
PARTITION BY product
ORDER BY month
) AS next_month_revenue
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | next_month_revenue |
|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 14500.00 |
| Widget A | 2024-02-01 | 14500.00 | 13200.00 |
| Widget A | 2024-03-01 | 13200.00 | 15800.00 |
| Widget A | 2024-04-01 | 15800.00 | 16100.00 |
| Widget A | 2024-05-01 | 16100.00 | NULL |
This time, the last row returns NULL because there is no future row to look at. Every other row successfully retrieves the revenue from the next month.
LEAD() with Offset
SELECT
ticker,
trade_date,
close_price,
LEAD(close_price, 1) OVER (ORDER BY trade_date) AS next_day_price,
LEAD(close_price, 2) OVER (ORDER BY trade_date) AS price_in_2_days
FROM daily_prices;
Output:
| ticker | trade_date | close_price | next_day_price | price_in_2_days |
|---|---|---|---|---|
| ACME | 2024-01-15 | 142.50 | 145.20 | 143.80 |
| ACME | 2024-01-16 | 145.20 | 143.80 | 148.00 |
| ACME | 2024-01-17 | 143.80 | 148.00 | 147.30 |
| ACME | 2024-01-18 | 148.00 | 147.30 | NULL |
| ACME | 2024-01-19 | 147.30 | NULL | NULL |
Period-Over-Period Comparisons
The most common real-world use of SQL LAG LEAD functions is period-over-period analysis: comparing a current value against a previous period to calculate differences, growth rates, or trends.
Month-Over-Month Change (Absolute and Percentage)
SELECT
product,
month,
revenue,
LAG(revenue) OVER (
PARTITION BY product ORDER BY month
) AS prev_revenue,
revenue - LAG(revenue) OVER (
PARTITION BY product ORDER BY month
) AS mom_change,
ROUND(
100.0 * (revenue - LAG(revenue) OVER (PARTITION BY product ORDER BY month))
/ LAG(revenue) OVER (PARTITION BY product ORDER BY month),
2
) AS mom_change_pct
FROM monthly_revenue;
Output:
| product | month | revenue | prev_revenue | mom_change | mom_change_pct |
|---|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | NULL | NULL | NULL |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | 2500.00 | 20.83 |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 | -1300.00 | -8.97 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 | 2600.00 | 19.70 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 | 300.00 | 1.90 |
| Widget B | 2024-01-01 | 8000.00 | NULL | NULL | NULL |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 | -800.00 | -10.00 |
| Widget B | 2024-03-01 | 9100.00 | 7200.00 | 1900.00 | 26.39 |
| Widget B | 2024-04-01 | 8800.00 | 9100.00 | -300.00 | -3.30 |
| Widget B | 2024-05-01 | 9500.00 | 8800.00 | 700.00 | 7.95 |
This single query gives you a complete month-over-month analysis. Widget A grew 20.83% from January to February but dipped 8.97% in March. Widget B dropped 10% in February before rebounding 26.39% in March.
Notice how LAG(revenue) OVER(...) is repeated multiple times in the query. Each usage triggers the same window computation. Most modern databases are smart enough to compute it once and reuse the result. However, for readability, consider using a CTE to compute the lagged value once:
WITH revenue_with_prev AS (
SELECT
product,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT
product,
month,
revenue,
prev_revenue,
revenue - prev_revenue AS mom_change,
ROUND(100.0 * (revenue - prev_revenue) / prev_revenue, 2) AS mom_change_pct
FROM revenue_with_prev;
Same result, but cleaner and easier to maintain.
Day-Over-Day Price Change
SELECT
ticker,
trade_date,
close_price,
LAG(close_price) OVER (ORDER BY trade_date) AS prev_close,
ROUND(close_price - LAG(close_price) OVER (ORDER BY trade_date), 2) AS price_change,
ROUND(
100.0 * (close_price - LAG(close_price) OVER (ORDER BY trade_date))
/ LAG(close_price) OVER (ORDER BY trade_date),
2
) AS change_pct
FROM daily_prices;
Output:
| ticker | trade_date | close_price | prev_close | price_change | change_pct |
|---|---|---|---|---|---|
| ACME | 2024-01-15 | 142.50 | NULL | NULL | NULL |
| ACME | 2024-01-16 | 145.20 | 142.50 | 2.70 | 1.89 |
| ACME | 2024-01-17 | 143.80 | 145.20 | -1.40 | -0.96 |
| ACME | 2024-01-18 | 148.00 | 143.80 | 4.20 | 2.92 |
| ACME | 2024-01-19 | 147.30 | 148.00 | -0.70 | -0.47 |
Detecting Trend Direction with LAG
You can combine LAG() with CASE to label each period's trend:
SELECT
product,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product ORDER BY month) AS prev_revenue,
CASE
WHEN LAG(revenue) OVER (PARTITION BY product ORDER BY month) IS NULL THEN 'N/A'
WHEN revenue > LAG(revenue) OVER (PARTITION BY product ORDER BY month) THEN 'UP'
WHEN revenue < LAG(revenue) OVER (PARTITION BY product ORDER BY month) THEN 'DOWN'
ELSE 'FLAT'
END AS trend
FROM monthly_revenue;
Output:
| product | month | revenue | prev_revenue | trend |
|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | NULL | N/A |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | UP |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 | DOWN |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 | UP |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 | UP |
| Widget B | 2024-01-01 | 8000.00 | NULL | N/A |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 | DOWN |
| Widget B | 2024-03-01 | 9100.00 | 7200.00 | UP |
| Widget B | 2024-04-01 | 8800.00 | 9100.00 | DOWN |
| Widget B | 2024-05-01 | 9500.00 | 8800.00 | UP |
FIRST_VALUE(): The First Row in the Window
FIRST_VALUE() returns the value from the first row of the window frame. It is commonly used to compare every row against a baseline, such as the first month's revenue, the initial stock price, or the earliest record in a partition.
Syntax
FIRST_VALUE(column) OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC]
[frame_clause]
)
Example: Compare Every Month to the First Month
SELECT
product,
month,
revenue,
FIRST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
) AS first_month_revenue,
revenue - FIRST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
) AS diff_from_start
FROM monthly_revenue;
Output:
| product | month | revenue | first_month_revenue | diff_from_start |
|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 12000.00 | 0.00 |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | 2500.00 |
| Widget A | 2024-03-01 | 13200.00 | 12000.00 | 1200.00 |
| Widget A | 2024-04-01 | 15800.00 | 12000.00 | 3800.00 |
| Widget A | 2024-05-01 | 16100.00 | 12000.00 | 4100.00 |
| Widget B | 2024-01-01 | 8000.00 | 8000.00 | 0.00 |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 | -800.00 |
| Widget B | 2024-03-01 | 9100.00 | 8000.00 | 1100.00 |
| Widget B | 2024-04-01 | 8800.00 | 8000.00 | 800.00 |
| Widget B | 2024-05-01 | 9500.00 | 8000.00 | 1500.00 |
Widget A has grown 4100 since its first month. Widget B dipped in February but ended up 1500 above its starting point by May. This "comparison to baseline" pattern is extremely common in financial reporting and KPI dashboards.
Example: Highest Earner's Name in Each Group
FIRST_VALUE() works on any data type, not just numbers. You can retrieve a text value from the first row:
SELECT
product,
month,
revenue,
FIRST_VALUE(month) OVER (
PARTITION BY product
ORDER BY revenue DESC
) AS best_month
FROM monthly_revenue;
Output:
| product | month | revenue | best_month |
|---|---|---|---|
| Widget A | 2024-05-01 | 16100.00 | 2024-05-01 |
| Widget A | 2024-04-01 | 15800.00 | 2024-05-01 |
| Widget A | 2024-02-01 | 14500.00 | 2024-05-01 |
| Widget A | 2024-03-01 | 13200.00 | 2024-05-01 |
| Widget A | 2024-01-01 | 12000.00 | 2024-05-01 |
| Widget B | 2024-05-01 | 9500.00 | 2024-05-01 |
| Widget B | 2024-03-01 | 9100.00 | 2024-05-01 |
| Widget B | 2024-04-01 | 8800.00 | 2024-05-01 |
| Widget B | 2024-01-01 | 8000.00 | 2024-05-01 |
| Widget B | 2024-02-01 | 7200.00 | 2024-05-01 |
By ordering by revenue DESC, the first row in each partition is the highest-revenue month. FIRST_VALUE(month) then pulls that month's date onto every row.
LAST_VALUE(): The Last Row in the Window
LAST_VALUE() returns the value from the last row of the window frame. In theory, this is the counterpart to FIRST_VALUE(). In practice, it has a notorious pitfall that catches almost everyone the first time.
Syntax
LAST_VALUE(column) OVER (
[PARTITION BY column1, ...]
ORDER BY column2 [ASC|DESC]
[frame_clause]
)
The LAST_VALUE() Trap
Let's try to get the last month's revenue (May) on every row:
-- COMMON MISTAKE: this does NOT work as expected
SELECT
product,
month,
revenue,
LAST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
) AS last_month_revenue
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | last_month_revenue |
|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 12000.00 |
| Widget A | 2024-02-01 | 14500.00 | 14500.00 |
| Widget A | 2024-03-01 | 13200.00 | 13200.00 |
| Widget A | 2024-04-01 | 15800.00 | 15800.00 |
| Widget A | 2024-05-01 | 16100.00 | 16100.00 |
That looks wrong. last_month_revenue is just mirroring the current row's revenue. Why?
When you use ORDER BY inside OVER(), the default window frame is:
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
This means the frame ends at the current row, not at the last row of the partition. So LAST_VALUE() returns the value of the current row because it is the last row the function can see.
This is not a bug. It is the default behavior. You must explicitly extend the frame to the end of the partition.
The Fix: Extend the Frame
To make LAST_VALUE() see the entire partition, specify the frame explicitly:
SELECT
product,
month,
revenue,
LAST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_month_revenue
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | last_month_revenue |
|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 16100.00 |
| Widget A | 2024-02-01 | 14500.00 | 16100.00 |
| Widget A | 2024-03-01 | 13200.00 | 16100.00 |
| Widget A | 2024-04-01 | 15800.00 | 16100.00 |
| Widget A | 2024-05-01 | 16100.00 | 16100.00 |
Now every row correctly shows 16100 (May's revenue) as the last month's value. The key addition is ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, which tells the database to consider all rows in the partition for this function.
FIRST_VALUE vs LAST_VALUE Side by Side
SELECT
product,
month,
revenue,
FIRST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS first_revenue,
LAST_VALUE(revenue) OVER (
PARTITION BY product
ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_revenue
FROM monthly_revenue;
Output:
| product | month | revenue | first_revenue | last_revenue |
|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 12000.00 | 16100.00 |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | 16100.00 |
| Widget A | 2024-03-01 | 13200.00 | 12000.00 | 16100.00 |
| Widget A | 2024-04-01 | 15800.00 | 12000.00 | 16100.00 |
| Widget A | 2024-05-01 | 16100.00 | 12000.00 | 16100.00 |
| Widget B | 2024-01-01 | 8000.00 | 8000.00 | 9500.00 |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 | 9500.00 |
| Widget B | 2024-03-01 | 9100.00 | 8000.00 | 9500.00 |
| Widget B | 2024-04-01 | 8800.00 | 8000.00 | 9500.00 |
| Widget B | 2024-05-01 | 9500.00 | 8000.00 | 9500.00 |
FIRST_VALUE() works correctly with the default frame because the first row of the partition is always included (the frame starts at UNBOUNDED PRECEDING). LAST_VALUE() needs an explicit extended frame because the default frame ends at CURRENT ROW, cutting off all following rows.
Frame Specification: ROWS BETWEEN
The ROWS BETWEEN clause gives you precise control over which rows a window function can access. This is the mechanism behind moving averages, sliding windows, and the LAST_VALUE() fix we just saw. Understanding it deeply will unlock the full potential of SQL LAG LEAD and all other window functions.
Syntax
function() OVER (
[PARTITION BY ...]
ORDER BY ...
ROWS BETWEEN <start_bound> AND <end_bound>
)
Available Boundaries
| Boundary | Meaning |
|---|---|
UNBOUNDED PRECEDING | First row of the partition |
N PRECEDING | N rows before the current row |
CURRENT ROW | The current row |
N FOLLOWING | N rows after the current row |
UNBOUNDED FOLLOWING | Last row of the partition |
Visual Representation
Imagine a partition with 7 rows and the current row is row 4:
Row 1 ← UNBOUNDED PRECEDING
Row 2 ← 2 PRECEDING
Row 3 ← 1 PRECEDING
Row 4 ← CURRENT ROW
Row 5 ← 1 FOLLOWING
Row 6 ← 2 FOLLOWING
Row 7 ← UNBOUNDED FOLLOWING
Common Frame Definitions
-- Running total (default when ORDER BY is present)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- Entire partition (needed for LAST_VALUE to work correctly)
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
-- 3-row moving window (current + 2 previous)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
-- Centered window (1 before, current, 1 after)
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
-- Forward-looking window
ROWS BETWEEN CURRENT ROW AND 3 FOLLOWING
ROWS vs RANGE
SQL also supports RANGE BETWEEN, which behaves differently from ROWS BETWEEN when there are tied values in the ORDER BY column:
| Keyword | Behavior |
|---|---|
ROWS | Counts physical rows regardless of their values |
RANGE | Includes all rows with the same value as the boundary row |
-- ROWS: strictly row-by-row, one at a time
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
-- RANGE: includes all peers (ties) up to the current value
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
For most use cases with LAG, LEAD, FIRST_VALUE, and LAST_VALUE, you will want ROWS since it gives more predictable, granular control.
When ORDER BY is present and no explicit frame is specified, the default in the SQL standard is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. This is the default behavior in PostgreSQL, SQL Server, and Oracle. Be aware that peer rows (rows with equal ORDER BY values) will be included together with RANGE, which can produce unexpected results for LAST_VALUE() even within a group of ties.
Practical Example: Frame Impact on FIRST_VALUE and LAST_VALUE
SELECT
product,
month,
revenue,
-- Default frame (UNBOUNDED PRECEDING to CURRENT ROW)
LAST_VALUE(revenue) OVER (
PARTITION BY product ORDER BY month
) AS last_default,
-- Full partition frame
LAST_VALUE(revenue) OVER (
PARTITION BY product ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) AS last_full,
-- 2-row trailing window
LAST_VALUE(revenue) OVER (
PARTITION BY product ORDER BY month
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
) AS last_2row
FROM monthly_revenue
WHERE product = 'Widget A';
Output:
| product | month | revenue | last_default | last_full | last_2row |
|---|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | 12000.00 | 16100.00 | 12000.00 |
| Widget A | 2024-02-01 | 14500.00 | 14500.00 | 16100.00 | 14500.00 |
| Widget A | 2024-03-01 | 13200.00 | 13200.00 | 16100.00 | 13200.00 |
| Widget A | 2024-04-01 | 15800.00 | 15800.00 | 16100.00 | 15800.00 |
| Widget A | 2024-05-01 | 16100.00 | 16100.00 | 16100.00 | 16100.00 |
last_default: Equals the current row (frame ends at current row)last_full: Always 16100 (frame covers entire partition)last_2row: Equals the current row (it is the last of the 2-row window)
This clearly shows how the frame definition changes what LAST_VALUE() returns.
Combining LAG, LEAD, FIRST_VALUE, and LAST_VALUE
Here is a comprehensive query that uses all four functions together for a complete analytical view:
SELECT
product,
month,
revenue,
LAG(revenue) OVER w AS prev_month,
LEAD(revenue) OVER w AS next_month,
FIRST_VALUE(revenue) OVER w_full AS first_month,
LAST_VALUE(revenue) OVER w_full AS last_month,
revenue - FIRST_VALUE(revenue) OVER w_full AS growth_from_start,
revenue - LAG(revenue) OVER w AS mom_change
FROM monthly_revenue
WINDOW
w AS (PARTITION BY product ORDER BY month),
w_full AS (PARTITION BY product ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING);
Output:
| product | month | revenue | prev_month | next_month | first_month | last_month | growth_from_start | mom_change |
|---|---|---|---|---|---|---|---|---|
| Widget A | 2024-01-01 | 12000.00 | NULL | 14500.00 | 12000.00 | 16100.00 | 0.00 | NULL |
| Widget A | 2024-02-01 | 14500.00 | 12000.00 | 13200.00 | 12000.00 | 16100.00 | 2500.00 | 2500.00 |
| Widget A | 2024-03-01 | 13200.00 | 14500.00 | 15800.00 | 12000.00 | 16100.00 | 1200.00 | -1300.00 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 | 16100.00 | 12000.00 | 16100.00 | 3800.00 | 2600.00 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 | NULL | 12000.00 | 16100.00 | 4100.00 | 300.00 |
| Widget B | 2024-01-01 | 8000.00 | NULL | 7200.00 | 8000.00 | 9500.00 | 0.00 | NULL |
| Widget B | 2024-02-01 | 7200.00 | 8000.00 | 9100.00 | 8000.00 | 9500.00 | -800.00 | -800.00 |
| Widget B | 2024-03-01 | 9100.00 | 7200.00 | 8800.00 | 8000.00 | 9500.00 | 1100.00 | 1900.00 |
| Widget B | 2024-04-01 | 8800.00 | 9100.00 | 9500.00 | 8000.00 | 9500.00 | 800.00 | -300.00 |
| Widget B | 2024-05-01 | 9500.00 | 8800.00 | NULL | 8000.00 | 9500.00 | 1500.00 | 700.00 |
The WINDOW clause (supported in PostgreSQL, MySQL 8+, and SQLite 3.28+) lets you define named windows and reuse them across multiple functions. Notice how w and w_full are defined once and referenced multiple times. This avoids repetition and makes the query significantly more readable.
If your database does not support named windows, simply inline the full OVER(...) clause for each function.
Common Mistakes to Avoid
Mistake 1: Forgetting ORDER BY with LAG/LEAD
-- WRONG: No ORDER BY means "previous" is meaningless
SELECT
product,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product) AS prev_revenue
FROM monthly_revenue;
Without ORDER BY, the database has no way to determine which row is "previous." Some databases will throw an error; others will return unpredictable results.
Fix: Always include ORDER BY:
SELECT
product,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product ORDER BY month) AS prev_revenue
FROM monthly_revenue;
Mistake 2: Using LAG/LEAD in WHERE
Like all window functions, LAG() and LEAD() cannot be used in WHERE or HAVING:
-- WRONG: Will fail
SELECT product, month, revenue
FROM monthly_revenue
WHERE revenue > LAG(revenue) OVER (PARTITION BY product ORDER BY month);
Fix: Wrap in a CTE or subquery:
WITH with_prev AS (
SELECT
product,
month,
revenue,
LAG(revenue) OVER (PARTITION BY product ORDER BY month) AS prev_revenue
FROM monthly_revenue
)
SELECT product, month, revenue, prev_revenue
FROM with_prev
WHERE revenue > prev_revenue;
Output:
| product | month | revenue | prev_revenue |
|---|---|---|---|
| Widget A | 2024-02-01 | 14500.00 | 12000.00 |
| Widget A | 2024-04-01 | 15800.00 | 13200.00 |
| Widget A | 2024-05-01 | 16100.00 | 15800.00 |
| Widget B | 2024-03-01 | 9100.00 | 7200.00 |
| Widget B | 2024-05-01 | 9500.00 | 8800.00 |
This returns only the months where revenue increased compared to the previous month.
Mistake 3: Not Extending the Frame for LAST_VALUE
This was covered in detail above, but it is worth repeating because it is the single most common error with LAST_VALUE():
-- WRONG: Returns the current row's value, not the partition's last value
LAST_VALUE(revenue) OVER (PARTITION BY product ORDER BY month)
-- CORRECT: Extends the frame to the end of the partition
LAST_VALUE(revenue) OVER (
PARTITION BY product ORDER BY month
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
Mistake 4: Confusing LAG/LEAD with FIRST_VALUE/LAST_VALUE
These function pairs solve different problems:
| Function | What it returns |
|---|---|
LAG(col, N) | The value from N rows before the current row |
LEAD(col, N) | The value from N rows after the current row |
FIRST_VALUE(col) | The value from the first row of the window frame |
LAST_VALUE(col) | The value from the last row of the window frame |
LAG and LEAD are relative to the current row (always a fixed offset). FIRST_VALUE and LAST_VALUE are relative to the window frame boundaries (which can be the entire partition or a sliding window). Do not use LAG(col, 3) when you actually need the first value of a partition. They may happen to produce the same result on row 4, but they will diverge on every other row.
Summary
The SQL LAG LEAD family of window functions gives you the ability to compare rows with their neighbors without self-joins or correlated subqueries:
LAG(column, offset, default)looks backward to retrieve a value from a previous row. Perfect for month-over-month and day-over-day comparisons.LEAD(column, offset, default)looks forward to retrieve a value from a subsequent row. Useful for forecasting comparisons and detecting future changes.FIRST_VALUE(column)returns the value from the first row of the window frame. Ideal for comparing every row against a baseline or starting point.LAST_VALUE(column)returns the value from the last row of the window frame. Requires an explicit frame (ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) to work as most people expect.- The
ROWS BETWEENframe specification controls exactly which rows a window function can see, enabling sliding windows, forward-looking computations, and correctLAST_VALUE()behavior.
These four functions are the backbone of period-over-period analysis, trend detection, and baseline comparisons in SQL. Combined with PARTITION BY, they let you perform these calculations independently within each group, making them indispensable for reporting and analytics.