LAG() (the previous row) and LEAD() (the next row) let you bring a value from another row alongside the current row and easily calculate a subtraction or other difference. This is another signature strength of window functions.
SELECT date, amount, LAG(amount) OVER(ORDER BY date ASC) AS prev_amount FROM daily_sales;
NULL.From the daily_sales table below, calculate each date's sales (amount), the previous day's sales, and the change from the previous day (diff).
| date | amount |
|---|---|
| 04-01 | 100 |
| 04-02 | 120 |
| 04-03 | 90 |
| 04-04 | 150 |
| date | amount | prev_amount | diff |
|---|---|---|---|
| 04-01 | 100 | NULL | NULL |
| 04-02 | 120 | 100 | 20 |
| 04-03 | 90 | 120 | -30 |
| 04-04 | 150 | 90 | 60 |
SELECT date, amount, LAG(amount) OVER(ORDER BY date) AS prev_amount, -- amount from the previous row in date order amount - LAG(amount) OVER(ORDER BY date) AS diff FROM daily_sales -- subtract the previous sale from the current sale ORDER BY date; /* Execution order: 1. FROM daily_sales 2. The window function evaluates rows in date order 3. SELECT produces the output */
LEGEND
① FROM
FROM daily_salesRead the table.| date | amount |
|---|---|
| 04-01 | 100 |
| 04-02 | 120 |
| 04-03 | 90 |
| 04-04 | 150 |
LAG(amount) OVER(PARTITION BY store_id ORDER BY date), LAG resets to NULL when the store changes. This prevents accidentally subtracting the previous day's sales from a different store.| date | amount | LAG(amount) — source | prev_amount | diff (subtraction) |
|---|---|---|---|---|
| 04-01 | 100 | No previous row → NULL | NULL | NULL |
| 04-02 | 120 | ↑ amount from 04-01 | 100 | +20 |
| 04-03 | 90 | ↑ amount from 04-02 | 120 | −30 |
| 04-04 | 150 | ↑ amount from 04-03 | 90 | +60 |
COALESCE(LAG(amount) OVER(...), 0).Window functions let you precisely specify the range of rows to aggregate, called the frame. Here, you will use a frame to calculate the moving average familiar from stock-price charts and other time-series analysis.
AVG(amount) OVER( ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW )
2 PRECEDING (two rows back) through CURRENT ROW (the current row), AVG covers at most three rows: the previous two rows and the current row. Because this table has one row per day, that is the latest three days.Using the daily_sales table, return each date's sales and the moving average over the latest three rows (from two rows back through the current row).
| date | amount |
|---|---|
| 04-01 | 100 |
| 04-02 | 140 |
| 04-03 | 120 |
| 04-04 | 190 |
| 04-05 | 170 |
| date | amount | moving_avg_3d |
|---|---|---|
| 04-01 | 100 | 100 |
| 04-02 | 140 | 120 |
| 04-03 | 120 | 120 |
| 04-04 | 190 | 150 |
| 04-05 | 170 | 160 |
For 04-01, the average is (100)/1=100; for 04-02, (100+140)/2=120; for 04-03, (100+140+120)/3=120; and for 04-04, (140+120+190)/3=150.
SELECT date, amount, AVG(amount) OVER( -- moving average without collapsing rows ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- latest 3 rows: current row + previous 2 ) AS moving_avg_3d FROM daily_sales ORDER BY date; /* Calculation: 04-01: no previous row, so average [04-01] → 100 04-02: one previous row, so average [04-01, 04-02] → (100+140)/2 = 120 04-03: two previous rows, so average [04-01, 04-02, 04-03] → 360/3 = 120 04-04: average [04-02, 04-03, 04-04] → (140+120+190)/3 = 150 */
LEGEND
① FROM
FROM daily_salesRead the table.| date | amount |
|---|---|
| 04-01 | 100 |
| 04-02 | 140 |
| 04-03 | 120 |
| 04-04 | 190 |
| 04-05 | 170 |
PRECEDING (an earlier row), FOLLOWING (a later row), UNBOUNDED PRECEDING (the first row), UNBOUNDED FOLLOWING (the last row), and CURRENT ROW (the current row).6 PRECEDING AND CURRENT ROW) reduces that noise and reveals the real upward or downward trend. When there is exactly one row per day, this is a seven-day moving average. It is a fundamental analytical technique.| date | amount | 04-01 100 |
04-02 140 |
04-03 120 |
04-04 190 |
04-05 170 |
moving_avg |
|---|---|---|---|---|---|---|---|
| 04-01 | 100 | ★ | 100 | ||||
| 04-02 | 140 | ○ | ★ | 120 | |||
| 04-03 | 120 | ○ | ○ | ★ | 120 | ||
| 04-04 | 190 | — | ○ | ○ | ★ | 150 | |
| 04-05 | 170 | — | — | ○ | ○ | ★ | 160 |
ORDER BY and omit ROWS BETWEEN, the implicit frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, from the first row through the current row. That produces a running aggregate rather than a moving average. Always specify the frame explicitly when you need a moving average.Because of SQL's logical execution order, you cannot put a window function directly in a WHERE clause. To filter rows by its result, run the window function inside a subquery (or CTE), then filter in the outer query's WHERE clause.
-- A highly reusable pattern for retrieving the latest history row SELECT * FROM ( SELECT *, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC, log_id DESC) AS rn FROM logs ) AS tmp WHERE rn = 1;
From the orders table below, retrieve every column from only the latest order row for each user: the row with the newest order_date. If dates tie, choose the row with the greatest order_id.
| order_id | user_id | order_date | item |
|---|---|---|---|
| 1 | U1 | 2024-04-01 | Mouse |
| 2 | U2 | 2024-04-02 | Desk |
| 3 | U1 | 2024-04-05 | Laptop |
| 4 | U3 | 2024-04-06 | Chair |
| 5 | U2 | 2024-04-08 | Monitor |
| order_id | user_id | order_date | item |
|---|---|---|---|
| 3 | U1 | 2024-04-05 | Laptop |
| 5 | U2 | 2024-04-08 | Monitor |
| 4 | U3 | 2024-04-06 | Chair |
SELECT order_id, user_id, order_date, item FROM ( -- Subquery: number each user's orders from newest to oldest SELECT order_id, user_id, order_date, item, ROW_NUMBER() OVER( PARTITION BY user_id ORDER BY order_date DESC, order_id DESC ) AS rn FROM orders ) AS tmp -- a subquery requires an alias (temporary name) WHERE rn = 1 -- outer query keeps only the single latest row ORDER BY user_id; /* Execution order: 1. FROM orders (inner query) 2. Window function (inner query): build a temporary table (tmp) with ROW_NUMBER U1: Laptop(rn=1), Mouse(rn=2) U2: Monitor(rn=1), Desk(rn=2) U3: Chair(rn=1) 3. FROM tmp (outer query) 4. WHERE rn = 1: keep only matching rows in the outer query 5. SELECT produces the output */
LEGEND
① Execute the Subquery
Assign ROW_NUMBER inside the subqueryThe inner query runs first and creates a virtual table (tmp) with rn numbering each user's orders from newest to oldest: order_date DESC, then order_id DESC for a date tie.| order_id | user_id | order_date | item | rn |
|---|---|---|---|---|
| 3 | U1 | 04-05 | Laptop | 1 |
| 1 | U1 | 04-01 | Mouse | 2 |
| 5 | U2 | 04-08 | Monitor | 1 |
| 2 | U2 | 04-02 | Desk | 2 |
| 4 | U3 | 04-06 | Chair | 1 |
SELECT user_id, MAX(order_date), item FROM orders GROUP BY user_id. In MySQL and other systems, item is either rejected or is not guaranteed to come from the row with the maximum date. To retrieve every column from the latest row, use the ROW_NUMBER + subquery pattern.| order_id | user_id | order_date | item | rn (newest first per user) |
|---|---|---|---|---|
| 3 | U1 | 04-05 | Laptop | 1 ← latest |
| 1 | U1 | 04-01 | Mouse | 2 |
| 5 | U2 | 04-08 | Monitor | 1 ← latest |
| 2 | U2 | 04-02 | Desk | 2 |
| 4 | U3 | 04-06 | Chair | 1 ← latest |
| order_id | user_id | order_date | item |
|---|---|---|---|
| 3 | U1 | 04-05 | Laptop |
| 5 | U2 | 04-08 | Monitor |
| 4 | U3 | 04-06 | Chair |
rn=1, so the result has more rows than users. Even ROW_NUMBER() chooses nondeterministically when equal timestamps have no tie-breaker. Extend ORDER BY through a unique column, such as order_id DESC.Extending Q8's latest-row pattern, you can change the filter to something like rn <= 3 to retrieve the top N rows in each group. When ties should share a rank, use RANK and filter the top N ranks instead. This is another powerful window-function operation that GROUP BY cannot perform.
From the products table below, retrieve the products in the top two price ranks within each category.
Products with the same price must share a rank and all be included. Output the rank (rnk) as well, so a tie can produce more than two products in a category.
| product_id | category | name | price |
|---|---|---|---|
| P1 | Appliances | TV | 80000 |
| P2 | Appliances | Refrigerator | 120000 |
| P3 | Appliances | Washing Machine | 90000 |
| P4 | Furniture | Bed | 50000 |
| P5 | Furniture | Sofa | 70000 |
| P6 | Furniture | Table | 30000 |
| category | name | price | rnk |
|---|---|---|---|
| Appliances | Refrigerator | 120000 | 1 |
| Appliances | Washing Machine | 90000 | 2 |
| Furniture | Sofa | 70000 | 1 |
| Furniture | Bed | 50000 | 2 |
SELECT category, name, price, rnk FROM ( SELECT category, name, price, RANK() OVER( -- rank by descending price within each category PARTITION BY category ORDER BY price DESC ) AS rnk FROM products ) AS tmp WHERE rnk <= 2 -- keep top 2 ranks; ties may yield more than 2 rows ORDER BY category, rnk; /* Execution order: 1. In the subquery, assign RANK by descending price within each category 2. In the outer query, filter on rnk <= 2 (the top two ranks) */
LEGEND
① Assign RANK() in the Subquery
PARTITION BY category ORDER BY price DESCAssign ranks independently within each category.| category | name | price | rnk |
|---|---|---|---|
| Appliances | Refrigerator | 120000 | 1 |
| Appliances | Washing Machine | 90000 | 2 |
| Appliances | TV | 80000 | 3 |
| Furniture | Sofa | 70000 | 1 |
| Furniture | Bed | 50000 | 2 |
| Furniture | Table | 30000 | 3 |
WITH ranked AS ( SELECT ... RANK() ... ) SELECT * FROM ranked WHERE rnk <= 2;. This gives you modern SQL that reads naturally from top to bottom.| category | name | price | RANK() PARTITION BY category ORDER BY price DESC |
WHERE rnk <= 2 |
|---|---|---|---|---|
| Appliances | Refrigerator | 120,000 | 1 | ✓ Included |
| Appliances | Washing Machine | 90,000 | 2 | ✓ Included |
| Appliances | TV | 80,000 | 3 | ✗ Excluded |
| Furniture | Sofa | 70,000 | 1 ← reset by partition | ✓ Included |
| Furniture | Bed | 50,000 | 2 | ✓ Included |
| Furniture | Table | 30,000 | 3 | ✗ Excluded |
PARTITION BY creates a rank within each category, not one overall rank across categories.
Production dashboards often place several aggregate metrics side by side in one query.
Bring together everything you have learned and use one SELECT to calculate current-month sales, running sales, previous-month sales, and the change from the previous month.
From the monthly_sales table below, output these four values for each month:
1. amount (current-month sales)
2. running_total (running sales from January)
3. prev_amount (previous-month sales; January may be NULL)
4. diff_from_prev (change in sales from the previous month).
| month | amount |
|---|---|
| Jan | 100 |
| Feb | 120 |
| Mar | 150 |
| Apr | 130 |
| May | 180 |
| month | amount | running_total | prev_amount | diff_from_prev |
|---|---|---|---|---|
| Jan | 100 | 100 | NULL | NULL |
| Feb | 120 | 220 | 100 | 20 |
| Mar | 150 | 370 | 120 | 30 |
| Apr | 130 | 500 | 150 | -20 |
| May | 180 | 680 | 130 | 50 |
SELECT month, amount, SUM(amount) OVER(ORDER BY month) AS running_total, -- ① running sales with an ordered SUM LAG(amount) OVER(ORDER BY month) AS prev_amount, -- ② previous-month sales from the prior row amount - LAG(amount) OVER(ORDER BY month) AS diff_from_prev -- ③ current-month sales - previous-month sales FROM monthly_sales ORDER BY month; /* Execution order: 1. FROM monthly_sales 2. Evaluate the window-function expressions in SELECT 3. ORDER BY sorts the output */
LEGEND
① Base Data
FROM monthly_salesRead the table.| month | amount |
|---|---|
| Jan | 100 |
| Feb | 120 |
| Mar | 150 |
| Apr | 130 |
| May | 180 |
| month | amount | SUM OVER(ORDER BY month) = running_total |
LAG(amount) OVER(...) = prev_amount |
amount − LAG() = diff_from_prev |
|---|---|---|---|---|
| Jan | 100 | 100 [100] |
NULL (first row) | NULL |
| Feb | 120 | 220 [100+120] |
100 ↑ Jan value |
+20 120−100 |
| Mar | 150 | 370 [100+120+150] |
120 ↑ Feb value |
+30 150−120 |
| Apr | 130 | 500 [100+120+150+130] |
150 ↑ Mar value |
−20 130−150 |
| May | 180 | 680 [100+120+150+130+180] |
130 ↑ Apr value |
+50 180−130 |