Value Functions: LAG, LEAD, FIRST_VALUE
Need to compare this month's sales to last month? Show the difference from a row's value to the first in its group? Value functions let you access data from other rows without self-joins.
LAG: Look Backward
LAG accesses data from a previous row.
LAG(column, offset, default) OVER (ORDER BY ...)
-- offset: how many rows back (default: 1)
-- default: value if no previous row (default: NULL)
Month-Over-Month Comparison
SELECT
month,
revenue,
LAG(revenue) OVER (ORDER BY month) as prev_month,
revenue - LAG(revenue) OVER (ORDER BY month) as change
FROM monthly_sales;
| month | revenue | prev_month | change |
|---|---|---|---|
| 2024-01 | 50000 | NULL | NULL |
| 2024-02 | 55000 | 50000 | +5000 |
| 2024-03 | 48000 | 55000 | -7000 |
| 2024-04 | 62000 | 48000 | +14000 |
Year-Over-Year (Looking Back 12 Rows)
SELECT
month,
revenue,
LAG(revenue, 12) OVER (ORDER BY month) as same_month_last_year,
ROUND(
100.0 * (revenue - LAG(revenue, 12) OVER (ORDER BY month))
/ LAG(revenue, 12) OVER (ORDER BY month),
1
) as yoy_growth_pct
FROM monthly_sales;
| month | revenue | same_month_last_year | yoy_growth_pct |
|---|---|---|---|
| 2024-01 | 55000 | 50000 | +10.0% |
| 2024-02 | 58000 | 52000 | +11.5% |
| 2024-03 | 51000 | 55000 | -7.3% |
LEAD: Look Forward
LEAD accesses data from a future row - the opposite of LAG.
LEAD(column, offset, default) OVER (ORDER BY ...)
Time Until Next Event
SELECT
customer_id,
order_date,
LEAD(order_date) OVER (
PARTITION BY customer_id
ORDER BY order_date
) as next_order,
LEAD(order_date) OVER (
PARTITION BY customer_id
ORDER BY order_date
) - order_date as days_until_next
FROM orders;
| customer_id | order_date | next_order | days_until_next |
|---|---|---|---|
| C001 | 2024-01-15 | 2024-02-02 | 18 days |
| C001 | 2024-02-02 | 2024-03-20 | 47 days |
| C001 | 2024-03-20 | NULL | NULL |
| C002 | 2024-01-20 | 2024-01-28 | 8 days |
Using Default Values
-- Use 0 instead of NULL when no previous value exists
SELECT
month,
revenue,
LAG(revenue, 1, 0) OVER (ORDER BY month) as prev_revenue
FROM monthly_sales;
FIRST_VALUE and LAST_VALUE
Access the first or last value in a partition.
Compare to First Value
SELECT
employee,
department,
hire_date,
salary,
FIRST_VALUE(salary) OVER (
PARTITION BY department
ORDER BY hire_date
) as first_hire_salary,
salary - FIRST_VALUE(salary) OVER (
PARTITION BY department
ORDER BY hire_date
) as salary_vs_first
FROM employees;
| employee | department | hire_date | salary | first_hire_salary | salary_vs_first |
|---|---|---|---|---|---|
| Alice | Engineering | 2020-01 | 80000 | 80000 | 0 |
| Bob | Engineering | 2021-06 | 95000 | 80000 | +15000 |
| Carol | Engineering | 2023-03 | 110000 | 80000 | +30000 |
LAST_VALUE Gotcha
By default, LAST_VALUE only sees up to the current row (because of the default frame). To see the true last value, you must specify the frame explicitly.
-- Wrong: returns current row's value (default frame)
LAST_VALUE(salary) OVER (PARTITION BY dept ORDER BY hire_date)
-- Correct: sees all rows in partition
LAST_VALUE(salary) OVER (
PARTITION BY dept
ORDER BY hire_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
)
Comparing to Min/Max
SELECT
product,
category,
price,
-- Lowest price in category (first when sorted by price)
FIRST_VALUE(price) OVER (
PARTITION BY category
ORDER BY price
) as min_price,
-- Highest price (last in price order, need full frame)
LAST_VALUE(price) OVER (
PARTITION BY category
ORDER BY price
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
) as max_price,
-- How this product compares
ROUND(100.0 * price / FIRST_VALUE(price) OVER (
PARTITION BY category ORDER BY price
), 0) as pct_of_min
FROM products;
| product | category | price | min_price | max_price | pct_of_min |
|---|---|---|---|---|---|
| Basic Chair | Furniture | $50 | $50 | $500 | 100% |
| Office Desk | Furniture | $200 | $50 | $500 | 400% |
| Executive Desk | Furniture | $500 | $50 | $500 | 1000% |
Real-World: Session Analysis
-- Calculate time spent on each page
SELECT
user_id,
page,
timestamp,
LEAD(timestamp) OVER (
PARTITION BY user_id
ORDER BY timestamp
) - timestamp as time_on_page
FROM page_views;
Real-World: Gap Analysis
-- Find days with no orders
SELECT
order_date,
LEAD(order_date) OVER (ORDER BY order_date) as next_order_date,
LEAD(order_date) OVER (ORDER BY order_date) - order_date - 1 as gap_days
FROM (
SELECT DISTINCT order_date FROM orders
) dates
WHERE LEAD(order_date) OVER (ORDER BY order_date) - order_date > 1;
Key Takeaways
- LAG looks backward - Previous row values, period comparisons
- LEAD looks forward - Next row values, time-to-event
- FIRST_VALUE/LAST_VALUE - Compare to extremes in partition
- LAST_VALUE needs explicit frame - Otherwise only sees up to current row
Next up: Window Frames - Fine-tune exactly which rows your window function sees.