Intermediate: combining and changing data · Window functions · topic 35 of 62 · 35 min
LAG, LEAD, FIRST_VALUE and LAST_VALUE
Explanation
Looking at the neighbour row
Many questions compare a row with the one before it: how much did sales change since yesterday, how many days passed since a customer's last order, did the stock price go up or down. Without window functions you would need a self-join on "the previous date", which is awkward. LAG and LEAD reach into the neighbouring row directly.
- LAG(expr) returns expr from the previous row in the window order. On the first row there is no previous row, so it returns NULL.
- LEAD(expr) returns expr from the next row. On the last row it returns NULL.
- Both take two optional arguments: LAG(expr, offset, default). LAG(revenue, 7) looks 7 rows back; LAG(revenue, 1, 0) returns 0 instead of NULL on the first row.
Day-over-day change and growth
SELECT sale_date, revenue,
LAG(revenue) OVER (ORDER BY sale_date) AS prev_day,
revenue - LAG(revenue) OVER (ORDER BY sale_date) AS change
FROM daily_sales
WHERE store = 'Pune'
ORDER BY sale_date;
-- 2025-03-01 | 12000 | NULL | NULL
-- 2025-03-02 | 15000 | 12000 | 3000
-- 2025-03-03 | 9000 | 15000 | -6000
-- 2025-03-04 | 18000 | 9000 | 9000
-- 2025-03-05 | 14000 | 18000 | -4000
-- 2025-03-06 | 21000 | 14000 | 7000Growth in percent is the change divided by the previous value, times 100. If the previous value could be 0, wrap it in NULLIF(prev, 0) so you get NULL instead of a division error.
ROUND(100.0 * (revenue - LAG(revenue) OVER w) / NULLIF(LAG(revenue) OVER w, 0), 1) AS growth_pct
-- 2025-03-02: 25 (12000 → 15000)
-- 2025-03-03: -40 (15000 → 9000)
-- 2025-03-04: 100 (9000 → 18000)Per customer: days between orders
Add PARTITION BY customer_id and LAG only looks back within the same customer. With SQLite's julianday(), which turns a date into a day number, you can subtract dates.
SELECT customer_id, id, order_date,
LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order,
julianday(order_date)
- julianday(LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date)) AS gap_days
FROM orders
ORDER BY customer_id, order_date;
-- 1 | 101 | 2025-01-05 | NULL | NULL
-- 1 | 103 | 2025-02-14 | 2025-01-05 | 40.0
-- 1 | 108 | 2025-04-02 | 2025-02-14 | 47.0
-- 2 | 102 | 2025-01-09 | NULL | NULL
-- 2 | 106 | 2025-03-15 | 2025-01-09 | 65.0
-- ...FIRST_VALUE and LAST_VALUE
FIRST_VALUE(expr) returns expr from the first row of the frame; LAST_VALUE(expr) from the last row of the frame. FIRST_VALUE(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) puts each customer's first order date on every one of their orders, handy for "days since the customer joined us".
The LAST_VALUE trap
Remember the default frame from topic 3: from the start of the partition up to the current row. So the "last row of the frame" is the current row itself, and LAST_VALUE just returns the current value. To get the true last value of the partition, widen the frame to the end.
SELECT sale_date, revenue,
LAST_VALUE(revenue) OVER (ORDER BY sale_date) AS wrong_last,
LAST_VALUE(revenue) OVER (ORDER BY sale_date
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) AS real_last
FROM daily_sales WHERE store = 'Pune' ORDER BY sale_date;
-- 2025-03-01 | 12000 | 12000 | 21000
-- 2025-03-02 | 15000 | 15000 | 21000
-- ...
-- 2025-03-06 | 21000 | 21000 | 21000The WINDOW clause
When several functions share the same OVER, name it once with a WINDOW clause after WHERE / GROUP BY / HAVING and before ORDER BY, then write OVER w. It works in SQLite, PostgreSQL, MySQL 8 and SQL Server 2022.
SELECT sale_date, revenue,
LAG(revenue) OVER w AS prev_day,
LEAD(revenue) OVER w AS next_day
FROM daily_sales
WHERE store = 'Pune'
WINDOW w AS (ORDER BY sale_date)
ORDER BY sale_date;Common mistakes
- Forgetting PARTITION BY, so LAG takes the previous row of a different customer or store.
- Forgetting that the first row gives NULL. Use the third argument (a default) or handle NULL in the report.
- Using LAST_VALUE with the default frame and getting the current row. Widen the frame or use FIRST_VALUE with DESC.
- Dividing by a previous value of 0. Use NULLIF(previous, 0).
- Assuming LAG(x, 1) means "yesterday" when days can be missing. It means the previous row.
Need help?
Stuck on a word or an idea? Search this course, or every course, and jump straight to the explanation or a worked question.
Try:
Watch the lesson
LAG, LEAD, FIRST_VALUE and LAST_VALUE · 1/7
Many questions compare a row with its neighbour: the change since yesterday, or the days since the last order.
Prefer a video by a teacher? Find free videos on “sql lag lead first_value last_value window function tutorial” on YouTube ↗
In real life: Spotting customers who are slipping away
Food delivery and e-commerce teams often watch the gap between a customer's orders. If someone who ordered every week has not ordered for a month, they may get a reminder or an offer. The gap is a LAG over each customer's orders, and the "days since first order" uses FIRST_VALUE. The same pattern shows month-on-month change in a bank statement or a UPI spending summary.
SELECT customer_id, id, order_date,
FIRST_VALUE(order_date) OVER w AS first_order,
LAG(order_date) OVER w AS prev_order
FROM orders
WINDOW w AS (PARTITION BY customer_id ORDER BY order_date)
ORDER BY customer_id, order_date;Practice (0/4 done)
For every order, show the date of the same customer's previous order. Return customer_id, id, order_date and the previous order date named prev_order (NULL for a customer's first order), sorted by customer_id, then order_date.
For the Mumbai store, return sale_date, revenue and the change from the previous day named change (revenue minus the previous day's revenue; NULL on the first day), sorted by sale_date.
Put each customer's first order date next to every one of their orders. Return customer_id, id, order_date and first_order, sorted by customer_id, then order_date.
How many days passed between each order and the same customer's previous order? Return customer_id, id and gap_days as a whole number (CAST of the julianday difference AS INTEGER; NULL for a first order). Only return orders of customers 1 and 2, sorted by customer_id, then order_date.
Quick quiz
Read more (free)
Related help
Before this, learn
This topic builds on these. If something here feels new, open them first.
Related topics
- Top-N per group and de-duplicationWindow functions · same module
- Ranking: ROW_NUMBER, RANK, DENSE_RANK and NTILEWindow functions · same module
- Classic interview queries, part 2Interview questions and practice projects
- Writing reports with aggregationAggregation: GROUP BY and HAVING
- Working with datesFunctions, CASE and NULL
- INSERT, UPDATE and DELETEBackend Development · Databases and SQL basics
Questions people ask about this
Open a question to see the answer and why. The first ones are this topic's own quiz, so try the quiz above before you peek.
What does LAG(revenue) return on the first row of its window?
Answer: NULL, unless a default is given as the third argument
There is no previous row. LAG(revenue, 1, 0) would give 0 instead.
Why does LAST_VALUE(revenue) OVER (ORDER BY sale_date) just return each row's own revenue?
Answer: The default frame ends at the current row, so the current row is the last one in the frame
Widen the frame to UNBOUNDED FOLLOWING, or use FIRST_VALUE with ORDER BY ... DESC.
You compute days between orders with LAG(order_date) OVER (ORDER BY order_date), without PARTITION BY. What goes wrong?
Answer: Each customer's first order gets compared with another customer's order
Partition by customer_id so the previous row is always the same customer.
Which expression gives the growth percentage from the previous row safely, even if the previous value is 0?
Answer: 100.0 * (revenue - LAG(revenue) OVER w) / NULLIF(LAG(revenue) OVER w, 0)
Change divided by previous value, with NULLIF turning a zero divisor into NULL.
What is the WINDOW clause for?
Answer: Naming a window once so several functions can say OVER w
It saves repeating the same PARTITION BY and ORDER BY.
Which frame gives a 3-day moving average when there is one row per day?Related
Answer: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
Two rows before plus the current row is three rows.
Which window function reads a value from the previous row?Related
employees has 11 rows. How many rows does SELECT name, AVG(salary) OVER (PARTITION BY dept_id) FROM employees return?Related
Answer: 11
A window function never removes rows; each of the 11 rows gets its department average.
orders JOIN order_items: what does one row of the result stand for?Related
Answer: One order line
The grain becomes the more detailed table, order_items.
Pass the quiz (60% or more) or mark this topic as done when you are ready.
Your progress is saved on this device only.
Next steps
- 1 more topic in “Window functions” before its exam: Top-N per group and de-duplication.
- Module 7 exam: Window functions · 14 questions · 25 min, without help. 5 topics of this module still to finish first.
- Then the next module, Changing data, transactions and ACID. 62 topics left in the course before the final exam.