By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
You’re analyzing time-series data—sales, server logs, stock prices, or user activity—and you need to compare each row to its neighbors. Maybe you want to: - Calculate month-over-month growth (current month’s revenue vs. last month’s).- Detect anomalies (a sudden spike in error logs compared to the previous hour).- Rank performance (how does today’s conversion rate compare to the best/worst day in the quarter?).
Without window functions like LAG, LEAD, FIRST_VALUE, and LAST_VALUE, you’d resort to: - Self-joins (slow, messy, and hard to maintain).- Cursors or loops (overkill for SQL).- Manual Excel work (error-prone and not scalable).
LAG
LEAD
FIRST_VALUE
LAST_VALUE
These functions let you peek at other rows in the same result set without joining the table to itself. They’re your secret weapon for: ✅ Trend analysis (e.g., "Did sales drop after a price increase?").✅ Gap detection (e.g., "Are there missing dates in this time series?").✅ Ranking and benchmarking (e.g., "How does this user’s engagement compare to the best/worst in their cohort?").
Real-world scenario:You’re analyzing a SaaS company’s subscription data. The CEO asks: "How many users downgraded their plan this month compared to last month?" With LAG, you can compare each user’s current plan to their previous one in a single query—no self-joins, no temporary tables.
LAG(column, offset, default)
offset
1
NULL
default
LEAD(column, offset, default)
FIRST_VALUE(column)
ORDER BY
LAST_VALUE(column)
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING
ROWS BETWEEN
RANGE BETWEEN
sql ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING -- Looks at previous, current, and next row RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- Default for most functions
PARTITION BY
GROUP BY
CREATE TABLE sales ( sale_id INT, sale_date DATE, product_id INT, revenue DECIMAL(10,2), user_id INT ); INSERT INTO sales VALUES (1, '2023-01-01', 101, 100.00, 1), (2, '2023-01-02', 101, 120.00, 1), (3, '2023-01-03', 102, 80.00, 2), (4, '2023-01-04', 101, 90.00, 1), (5, '2023-01-05', 103, 200.00, 3), (6, '2023-01-06', 102, 85.00, 2);
Goal: For each sale, show: 1. The previous day’s revenue for the same product.2. The next day’s revenue for the same product.3. The first and last revenue for each product.
SELECT sale_date, product_id, revenue, LAG(revenue, 1) OVER (PARTITION BY product_id ORDER BY sale_date) AS prev_day_revenue FROM sales;
Output:| sale_date | product_id | revenue | prev_day_revenue | |------------|------------|---------|------------------| | 2023-01-01 | 101 | 100.00 | NULL | | 2023-01-02 | 101 | 120.00 | 100.00 | | 2023-01-04 | 101 | 90.00 | 120.00 | | 2023-01-03 | 102 | 80.00 | NULL | | 2023-01-06 | 102 | 85.00 | 80.00 | | 2023-01-05 | 103 | 200.00 | NULL |
Why it works:- PARTITION BY product_id groups sales by product.- ORDER BY sale_date sorts sales chronologically.- LAG(revenue, 1) looks at the previous row’s revenue.
PARTITION BY product_id
ORDER BY sale_date
LAG(revenue, 1)
SELECT sale_date, product_id, revenue, LEAD(revenue, 1) OVER (PARTITION BY product_id ORDER BY sale_date) AS next_day_revenue FROM sales;
Output:| sale_date | product_id | revenue | next_day_revenue | |------------|------------|---------|------------------| | 2023-01-01 | 101 | 100.00 | 120.00 | | 2023-01-02 | 101 | 120.00 | 90.00 | | 2023-01-04 | 101 | 90.00 | NULL | | 2023-01-03 | 102 | 80.00 | 85.00 | | 2023-01-06 | 102 | 85.00 | NULL | | 2023-01-05 | 103 | 200.00 | NULL |
SELECT sale_date, product_id, revenue, FIRST_VALUE(revenue) OVER (PARTITION BY product_id ORDER BY sale_date) AS first_revenue, LAST_VALUE(revenue) OVER ( PARTITION BY product_id ORDER BY sale_date RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING -- Critical for LAST_VALUE! ) AS last_revenue FROM sales;
Output:| sale_date | product_id | revenue | first_revenue | last_revenue | |------------|------------|---------|---------------|--------------| | 2023-01-01 | 101 | 100.00 | 100.00 | 90.00 | | 2023-01-02 | 101 | 120.00 | 100.00 | 90.00 | | 2023-01-04 | 101 | 90.00 | 100.00 | 90.00 | | 2023-01-03 | 102 | 80.00 | 80.00 | 85.00 | | 2023-01-06 | 102 | 85.00 | 80.00 | 85.00 | | 2023-01-05 | 103 | 200.00 | 200.00 | 200.00 |
Why LAST_VALUE needs a frame:- Without RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING, LAST_VALUE would return the current row’s value (not the last in the partition).
WITH daily_sales AS ( SELECT sale_date, product_id, revenue, LAG(revenue, 1) OVER (PARTITION BY product_id ORDER BY sale_date) AS prev_revenue FROM sales ) SELECT sale_date, product_id, revenue, prev_revenue, ROUND((revenue - prev_revenue) / prev_revenue * 100, 2) AS pct_growth FROM daily_sales WHERE prev_revenue IS NOT NULL;
Output:| sale_date | product_id | revenue | prev_revenue | pct_growth | |------------|------------|---------|--------------|------------| | 2023-01-02 | 101 | 120.00 | 100.00 | 20.00 | | 2023-01-04 | 101 | 90.00 | 120.00 | -25.00 | | 2023-01-06 | 102 | 85.00 | 80.00 | 6.25 |
SELECT *
LAG(revenue) AS prev_revenue
-- RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING for LAST_VALUE
LAG(revenue, 1, 0)
0
sale_date
prev_
next_
LAG/LEAD
COALESCE
DEFAULT
MIN()
✅ LAG (correct)
"How do you find the last value in a partition?"
✅ LAST_VALUE(column) OVER (ORDER BY ... RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
LAST_VALUE(column) OVER (ORDER BY ... RANGE BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)
"What’s the difference between ROWS BETWEEN and RANGE BETWEEN?"
RANGE BETWEEN: Logical values (e.g., "all rows with the same date").
"How would you calculate a 7-day moving average?" sql AVG(revenue) OVER ( ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW )
sql AVG(revenue) OVER ( ORDER BY sale_date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW )
MIN
Challenge:You have a table user_activity with columns user_id, activity_date, and session_duration. Write a query to: 1. Show each user’s session duration.2. Compare it to their previous session’s duration.3. Flag sessions where duration dropped by >50% compared to the previous session.
user_activity
user_id
activity_date
session_duration
Solution:
SELECT user_id, activity_date, session_duration, LAG(session_duration, 1) OVER (PARTITION BY user_id ORDER BY activity_date) AS prev_duration, CASE WHEN LAG(session_duration, 1) OVER (PARTITION BY user_id ORDER BY activity_date) IS NOT NULL AND session_duration < 0.5 * LAG(session_duration, 1) OVER (PARTITION BY user_id ORDER BY activity_date) THEN 'DROP >50%' ELSE 'No drop' END AS drop_flag FROM user_activity;
Why it works:- LAG fetches the previous session’s duration.- The CASE statement compares current vs. previous duration.- PARTITION BY user_id ensures comparisons are per user.
CASE
PARTITION BY user_id
LAG(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)
LEAD(column, offset, default) OVER (PARTITION BY ... ORDER BY ...)
FIRST_VALUE(column) OVER (PARTITION BY ... ORDER BY ...)
LAST_VALUE(column) OVER (PARTITION BY ... ORDER BY ... RANGE BETWEEN ...)
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.