LAG(column, n) brings the value from n rows before into the current row, while LEAD(column, n) brings in the value from n rows after. Both use OVER(ORDER BY ...) to define the row order.
SELECT month, revenue, LAG(revenue, 1) OVER(ORDER BY month) AS prev_revenue, -- one row before LEAD(revenue, 1) OVER(ORDER BY month) AS next_revenue -- one row after FROM monthly_revenue;
LAG(revenue, 2) references the value two rows before. If omitted, the offset defaults to 1, the immediately preceding row. The third argument supplies a default value to return instead of NULL when the referenced row does not exist, as in LAG(revenue, 1, 0).Using the monthly_revenue table below, output each month's revenue together with the previous month's revenue (prev_revenue), the next month's revenue (next_revenue), and the change from the previous month (diff_from_prev).
Confirm that the value is NULL when the preceding or following row does not exist.
| month | revenue |
|---|---|
| 2024-01 | 400000 |
| 2024-02 | 460000 |
| 2024-03 | 430000 |
| 2024-04 | 510000 |
| 2024-05 | 480000 |
| month | revenue | prev_revenue | next_revenue | diff_from_prev |
|---|---|---|---|---|
| 2024-01 | 400,000 | NULL | 460,000 | NULL |
| 2024-02 | 460,000 | 400,000 | 430,000 | +60,000 |
| 2024-03 | 430,000 | 460,000 | 510,000 | -30,000 |
| 2024-04 | 510,000 | 430,000 | 480,000 | +80,000 |
| 2024-05 | 480,000 | 510,000 | NULL | -30,000 |
SELECT month, revenue, LAG(revenue) OVER(ORDER BY month) AS prev_revenue, -- revenue one row before (NULL on the first row) LEAD(revenue) OVER(ORDER BY month) AS next_revenue, -- revenue one row after (NULL on the last row) revenue - LAG(revenue) OVER(ORDER BY month) AS diff_from_prev -- current month minus previous row (NULL on the first row) FROM monthly_revenue ORDER BY month; /* Logical evaluation order: 1. FROM monthly_revenue → read 5 rows 2. Window functions (LAG/LEAD) → attach adjacent revenue values 3. diff_from_prev → calculate the change from the previous row 4. SELECT → project the columns 5. ORDER BY month → sort ascending */
LEGEND
① FROM
FROM monthly_revenueRead all 5 rows from monthly_revenue.| month | revenue |
|---|---|
| 2024-01 | 400,000 |
| 2024-02 | 460,000 |
| 2024-03 | 430,000 |
| 2024-04 | 510,000 |
| 2024-05 | 480,000 |
t1.revenue - t2.revenue. LAG expresses the same adjacent-row lookup directly; the optimizer determines the physical execution plan. It is indispensable for production KPI-trend and dashboard queries.LAG(revenue, 1, 0) puts 0 on the first row, where no preceding row exists. Use the third argument when requirements say, for example, to treat the first month's prior-period value as zero.LAG(revenue) OVER(PARTITION BY dept ORDER BY month) calculates month-over-month changes within each department. The reference window resets when the department changes, preventing a row from referring to the previous row of another department.| month | revenue | LAG (one row before) | LEAD (one row after) |
|---|---|---|---|
| 2024-01 | 400,000 | — | — |
| ⇧ 2024-02 | 460,000 | ▲ Row referenced by LAG | — |
| ▶ 2024-03 (current row) | 430,000 | 460,000 | 510,000 |
| ⇩ 2024-04 | 510,000 | — | ▼ Row referenced by LEAD |
| 2024-05 | 480,000 | — | — |
LAG(revenue) OVER(), the engine may evaluate rows in an unspecified order, so the "previous row" is indeterminate. Always pair LAG/LEAD with OVER(ORDER BY columns_that_define_the_order).NTILE(n) divides data sorted by ORDER BY into n nearly equal buckets and assigns each row a bucket number from 1 through n.
NTILE(4) OVER(ORDER BY amount DESC) AS quartile -- sort every row by amount descending and assign a bucket number (1–4)
Using the customers table below, which contains eight customers and their total purchase amounts, divide the rows into four equal groups in descending total_amount order with NTILE(4), and assign a quartile number.
quartile=1 is the highest-spending group (top 25%), and quartile=4 is the lowest-spending group.
| customer_id | name | total_amount |
|---|---|---|
| C1 | Tanaka | 85,000 |
| C2 | Sato | 42,000 |
| C3 | Suzuki | 120,000 |
| C4 | Takahashi | 30,000 |
| C5 | Ito | 75,000 |
| C6 | Watanabe | 98,000 |
| C7 | Nakamura | 15,000 |
| C8 | Kobayashi | 55,000 |
| customer_id | name | total_amount | quartile |
|---|---|---|---|
| C3 | Suzuki | 120,000 | 1 |
| C6 | Watanabe | 98,000 | 1 |
| C1 | Tanaka | 85,000 | 2 |
| C5 | Ito | 75,000 | 2 |
| C8 | Kobayashi | 55,000 | 3 |
| C2 | Sato | 42,000 | 3 |
| C4 | Takahashi | 30,000 | 4 |
| C7 | Nakamura | 15,000 | 4 |
SELECT customer_id, name, total_amount, NTILE(4) OVER(ORDER BY total_amount DESC) AS quartile -- four descending buckets (2 rows each; 1 = highest-spending VIP) FROM customers ORDER BY total_amount DESC; /* Logical evaluation order: 1. FROM customers → read 8 rows 2. Window function NTILE(4) → divide by total_amount DESC and assign bucket numbers 3. SELECT → project the columns 4. ORDER BY total_amount DESC → perform the final sort */
LEGEND
① FROM
FROM customersRead all 8 rows from customers. Their order is unspecified at this point.| customer_id | name | total_amount |
|---|---|---|
| C1 | Tanaka | 85,000 |
| C2 | Sato | 42,000 |
| C3 | Suzuki | 120,000 |
| C4 | Takahashi | 30,000 |
| C5 | Ito | 75,000 |
| C6 | Watanabe | 98,000 |
| C7 | Nakamura | 15,000 |
| C8 | Kobayashi | 55,000 |
NTILE(4) OVER(PARTITION BY region ORDER BY amount DESC) calculates quartiles within each region. This supports relative evaluations such as identifying VIPs within the eastern region rather than across the entire company.NTILE(10) for decile analysis. Customers are split into ten groups by purchase amount so that campaigns for the top 10% (decile 1) can differ from campaigns for everyone else.| rank | total_amount | quartile (NTILE=4) | note |
|---|---|---|---|
| 1st | 120,000 | 1 | 1 extra row → added to bucket 1 |
| 2nd | 98,000 | 1 | |
| 3rd | 90,000 | 1 ← 3 rows | ⇦ remainder goes here |
| 4th | 85,000 | 2 | |
| 5th | 75,000 | 2 ← 2 rows | |
| 6th | 55,000 | 3 | |
| 7th | 42,000 | 3 ← 2 rows | |
| 8th | 30,000 | 4 | |
| 9th | 15,000 | 4 ← 2 rows |
NTILE(4), however, returns a group-membership number from 1 through 4. NTILE tells you which bucket a row belongs to, not the bucket's percentile boundary. Use PERCENTILE_CONT when you need boundary values.ORDER BY total_amount DESC, customer_id when assignments must be deterministic.Writing ROWS BETWEEN start AND end inside OVER precisely defines which range of rows forms the aggregation frame. This is one of the most powerful features of window functions.
AVG(temp) OVER( ORDER BY date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- from 2 rows before through the current row = latest 3 rows )
•
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW — first row through current row (running aggregate)•
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW — two rows before through current row (latest three rows)•
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING — every row in the partition
ROWS defines a frame by physical row count. RANGE defines it by ORDER BY values and treats peer rows with the same ordering value together. Use ROWS for calculations based on a physical number of rows.From the daily_temperature table below, calculate the temperature's three-day moving average including the current day as moving_avg_3d, rounded to one decimal place. The data contains exactly one row per consecutive calendar day, so the latest three rows represent the latest three days.
The first two rows do not yet have three days of data, so average only the rows available in their shortened frames, as shown in the output.
| measured_date | temp |
|---|---|
| 2024-07-01 | 28.5 |
| 2024-07-02 | 31.2 |
| 2024-07-03 | 33.0 |
| 2024-07-04 | 29.8 |
| 2024-07-05 | 32.1 |
| 2024-07-06 | 35.4 |
| measured_date | temp | moving_avg_3d | calculation (reference) |
|---|---|---|---|
| 2024-07-01 | 28.5 | 28.5 | 28.5 ÷ 1 |
| 2024-07-02 | 31.2 | 29.9 | (28.5+31.2) ÷ 2 |
| 2024-07-03 | 33.0 | 30.9 | (28.5+31.2+33.0) ÷ 3 |
| 2024-07-04 | 29.8 | 31.3 | (31.2+33.0+29.8) ÷ 3 |
| 2024-07-05 | 32.1 | 31.6 | (33.0+29.8+32.1) ÷ 3 |
| 2024-07-06 | 35.4 | 32.4 | (29.8+32.1+35.4) ÷ 3 |
SELECT measured_date, temp, ROUND( AVG(temp) OVER( ORDER BY measured_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW -- 3-row frame from 2 rows before to current (shortened at the start) ) , 1) AS moving_avg_3d -- ROUND(..., 1): round to one decimal place FROM daily_temperature ORDER BY measured_date; /* Logical evaluation order: 1. FROM daily_temperature → read 6 rows 2. Window function → calculate the moving average over each frame 3. ROUND → round to one decimal place 4. SELECT → project the columns */
LEGEND
① FROM
FROM daily_temperatureRead all 6 rows from daily_temperature.| measured_date | temp |
|---|---|
| 07-01 | 28.5 |
| 07-02 | 31.2 |
| 07-03 | 33.0 |
| 07-04 | 29.8 |
| 07-05 | 32.1 |
| 07-06 | 35.4 |
ROWS when the requirement is explicitly the latest N rows; use a dialect-appropriate date interval when the requirement is a true calendar period over data that may have gaps or multiple rows per day.PARTITION BY product_id ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW calculates a seven-row moving average per product. With exactly one row per consecutive day, that is a seven-day average. The calculation never crosses a partition boundary.| measured_date | temp | rows in frame (blue = current, dim = preceding) | avg |
|---|---|---|---|
| 07-01 | 28.5 | 28.5 | 28.5 |
| 07-02 | 31.2 | 28.5 + 31.2 | 29.9 |
| 07-03 | 33.0 | 28.5 + 31.2 + 33.0 | 30.9 |
| 07-04 | 29.8 | 31.2 + 33.0 + 29.8 ← 07-01 leaves the frame | 31.3 |
AVG(temp) OVER(ORDER BY date), the usual default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, including peers with the same ordering value. That produces a running average rather than a fixed-width moving average. Explicitly write ROWS BETWEEN N PRECEDING AND CURRENT ROW for a moving average over a fixed number of rows.FIRST_VALUE(column) returns the value from the first row in the window frame, while LAST_VALUE(column) returns the value from the last row.
FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_price
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING, the usual ORDER BY default ends at the current row's peer group rather than at the partition's final row. With the unique dates in this example, the current row is therefore the frame's last row and LAST_VALUE simply returns the current row's price. This is one of SQL's best-known silent logic bugs.From the stock_prices table below, attach the period's opening price (first_price) and closing price (last_price) for each stock to every row.
When using LAST_VALUE, explicitly specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING so the frame reaches the partition's final row.
| stock | trade_date | price |
|---|---|---|
| TYK | 2024-04-01 | 1,200 |
| TYK | 2024-04-02 | 1,150 |
| TYK | 2024-04-03 | 1,280 |
| OSK | 2024-04-01 | 850 |
| OSK | 2024-04-02 | 920 |
| OSK | 2024-04-03 | 890 |
| stock | trade_date | price | first_price | last_price |
|---|---|---|---|---|
| OSK | 2024-04-01 | 850 | 850 | 890 |
| OSK | 2024-04-02 | 920 | 850 | 890 |
| OSK | 2024-04-03 | 890 | 850 | 890 |
| TYK | 2024-04-01 | 1,200 | 1,200 | 1,280 |
| TYK | 2024-04-02 | 1,150 | 1,200 | 1,280 |
| TYK | 2024-04-03 | 1,280 | 1,200 | 1,280 |
SELECT stock, trade_date, price, FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- whole-partition frame, explicit for symmetry with LAST_VALUE ) AS first_price, LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING -- required whole-partition frame; omission stops at the current peer group ) AS last_price FROM stock_prices ORDER BY stock, trade_date; /* Logical evaluation order: 1. FROM stock_prices → read 6 rows 2. PARTITION BY stock → split into TYK (3 rows) and OSK (3 rows) 3. ORDER BY trade_date within each partition 4. FIRST_VALUE → attach the partition's first price to every row 5. LAST_VALUE → UNBOUNDED FOLLOWING makes the partition's final price available on every row 6. SELECT → project the columns 7. ORDER BY stock, trade_date → perform the final sort */
LEGEND
① FROM
FROM stock_pricesRead all 6 rows from stock_prices.| stock | trade_date | price |
|---|---|---|
| TYK | 04-01 | 1,200 |
| TYK | 04-02 | 1,150 |
| TYK | 04-03 | 1,280 |
| OSK | 04-01 | 850 |
| OSK | 04-02 | 920 |
| OSK | 04-03 | 890 |
MAX(price) OVER(PARTITION BY stock) attaches each stock's highest price to every row. If you need the maximum or minimum rather than the first or last value in time, MAX/MIN are simpler and avoid the LAST_VALUE pitfall.LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date -- frame omitted! -- usual default ends at current peer group )
| trade_date | price | last_price (bug) |
|---|---|---|
| 04-01 | 1,200 | 1,200 ← itself! |
| 04-02 | 1,150 | 1,150 ← itself! |
| 04-03 | 1,280 | 1,280 ← itself! |
LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING )
| trade_date | price | last_price (correct) |
|---|---|---|
| 04-01 | 1,200 | 1,280 ✓ |
| 04-02 | 1,150 | 1,280 ✓ |
| 04-03 | 1,280 | 1,280 ✓ |
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.RANGE BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING also spans the whole partition and produces the same result in this query. ROWS BETWEEN is used here to state explicitly that the frame consists of all physical rows and to avoid peer-value semantics when adapting the pattern to bounded frames.A window-function result cannot be filtered directly in WHERE because WHERE is evaluated before window functions. In production, calculate the window function first in a CTE (Common Table Expression) or subquery, then filter its result in the outer WHERE.
WITH cte_name AS ( -- calculate the window function here SELECT *, ROW_NUMBER() OVER(...) AS rn FROM table ) SELECT * FROM cte_name WHERE rn <= 2; -- filter by the column created in the CTE
From the emp_sales table below, return the top two employees in each department (dept) by descending sales. Implement the query with a CTE.
| dept | emp_name | sales |
|---|---|---|
| Sales | Tanaka | 850,000 |
| Sales | Sato | 720,000 |
| Sales | Suzuki | 930,000 |
| Engineering | Takahashi | 410,000 |
| Engineering | Ito | 380,000 |
| Engineering | Watanabe | 450,000 |
| dept | emp_name | sales |
|---|---|---|
| Sales | Suzuki | 930,000 |
| Sales | Tanaka | 850,000 |
| Engineering | Watanabe | 450,000 |
| Engineering | Takahashi | 410,000 |
WITH ranked AS ( -- calculate the window function in the CTE (it cannot be filtered here with WHERE) SELECT dept, emp_name, sales, ROW_NUMBER() OVER( PARTITION BY dept -- reset numbering per department ORDER BY sales DESC, emp_name -- rank by sales; break ties by name ) AS rn FROM emp_sales ) SELECT dept, emp_name, sales FROM ranked WHERE rn <= 2 -- keep the top two per department ORDER BY CASE dept WHEN 'Sales' THEN 1 WHEN 'Engineering' THEN 2 END, sales DESC; /* Logical evaluation order: 1. Inner CTE query → assign ranks with PARTITION + ORDER + ROW_NUMBER 2. CTE result → expose the result as a virtual table 3. Outer SELECT → filter with WHERE rn and sort */
LEGEND
① FROM (inside CTE)
FROM emp_sales (inside CTE ranked)The CTE's inner query runs first and reads all 6 rows from emp_sales.| dept | emp_name | sales |
|---|---|---|
| Sales | Tanaka | 850,000 |
| Sales | Sato | 720,000 |
| Sales | Suzuki | 930,000 |
| Engineering | Takahashi | 410,000 |
| Engineering | Ito | 380,000 |
| Engineering | Watanabe | 450,000 |
RANK() OVER(...) AS rn with WHERE rn <= 2; then every employee tied for second is returned.QUALIFY rn <= 2, allowing direct filtering without a CTE. PostgreSQL and MySQL do not currently support it, so the CTE pattern remains the most portable.| dept | emp_name | sales | rn (ROW_NUMBER) | WHERE rn<=2 result |
|---|---|---|---|---|
| Sales | Suzuki | 930,000 | 1 | ✓ included |
| Sales | Tanaka | 850,000 | 2 | ✓ included |
| Sales | Sato | 720,000 | 3 | ✗ excluded |
| Engineering | Watanabe | 450,000 | 1 | ✓ included |
| Engineering | Takahashi | 410,000 | 2 | ✓ included |
| Engineering | Ito | 380,000 | 3 | ✗ excluded |
SELECT dept, emp_name, ROW_NUMBER() OVER(...) AS rn FROM emp_sales WHERE rn <= 2 fails with an error such as "column rn does not exist" or "Window functions are not allowed in WHERE." Nest one level with a CTE or subquery before filtering a window result.ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rn → WHERE rn = 1), report the five best-selling products in each category, or list each store's lowest-selling representative with ORDER BY sales ASC. Selecting the top or bottom N rows within groups appears throughout data analysis. Writing this pattern reflexively is a hallmark of intermediate SQL proficiency.