Window Functions — Learn LAG/LEAD, NTILE, Moving Averages, FIRST/LAST VALUE, and CTEs in Practice
AdvancedWindow FunctionsAdjacent Rows (LAG/LEAD)NTILE / SegmentationMoving Averages / FramesFIRST / LAST VALUEPostgreSQL/MySQL 8.0+/BigQuery5 questions
QUESTION 1
LAG() / LEAD() — Reference Adjacent Rows to Calculate Month-over-Month Changes
LAGLEADMonth-over-MonthTime-Series AnalysisKPI Trends
Background

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;
Offset (second argument): 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).
Why LAG/LEAD were introduced: Comparing this month's sales with the previous month's once required a self join against the same table. LAG/LEAD eliminate that join boilerplate and express the adjacent-row comparison directly.
Problem

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.

Table
▶ monthly_revenue
monthrevenue
2024-01400000
2024-02460000
2024-03430000
2024-04510000
2024-05480000
Expected Output
monthrevenueprev_revenuenext_revenuediff_from_prev
2024-01400,000NULL460,000NULL
2024-02460,000400,000430,000+60,000
2024-03430,000460,000510,000-30,000
2024-04510,000430,000480,000+80,000
2024-05480,000510,000NULL-30,000
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT month, revenue, LAG(revenue) OVER(ORDER BY month) AS prev_revenue, LEAD(revenue) OVER(ORDER BY month) AS next_revenue, revenue - LAG(revenue) OVER(ORDER BY month) AS diff_from_prev FROM monthly_revenue ORDER BY month;
LEGEND
Rows read / loaded
① FROM
FROM monthly_revenueRead all 5 rows from monthly_revenue.
1 / 3
monthrevenue
2024-01400,000
2024-02460,000
2024-03430,000
2024-04510,000
2024-05480,000
5 rows read
LEARNING POINTS
No self join required: Before LAG/LEAD, a month-over-month comparison typically required a self join such as 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.
A default value can avoid NULL: 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.
Combine it with PARTITION BY: 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.
▶ LAG / LEAD reference directions — with 2024-03 as the current row
monthrevenueLAG (one row before)LEAD (one row after)
2024-01400,000
⇧ 2024-02460,000 ▲ Row referenced by LAG
▶ 2024-03 (current row)430,000 460,000 510,000
⇩ 2024-04510,000 ▼ Row referenced by LEAD
2024-05480,000
LAG looks upward to a preceding row, while LEAD looks downward to a following row. With PARTITION BY, neither function crosses a partition boundary.
ANTI-PATTERNS
Omitting ORDER BY: Without ORDER BY, as in 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).
Mixing groups by forgetting PARTITION BY: If a table contains multiple departments or products and PARTITION BY is omitted, the next row after the last row of department A may be the first row of department B. Prevent cross-group references with PARTITION BY.
QUESTION 2
NTILE() — Classify Customers into Purchase-Amount Quartiles
NTILEQuartilesSegmentationCustomer Analysis
Background

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)
Rows per bucket: If the total row count is not divisible by n, the extra rows are added one at a time to the earlier, lower-numbered buckets. For example, 10 rows divided among 4 buckets are distributed as 3, 3, 2, and 2 rows.
Production uses: NTILE is useful whenever you need relative bands across a population—for example, segmenting e-commerce customers by spend (VIP/loyal/regular/low-frequency) or placing employee ratings into five relative tiers, sometimes called a forced distribution.
Problem

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.

Table
▶ customers
customer_idnametotal_amount
C1Tanaka85,000
C2Sato42,000
C3Suzuki120,000
C4Takahashi30,000
C5Ito75,000
C6Watanabe98,000
C7Nakamura15,000
C8Kobayashi55,000
Expected Output
customer_idnametotal_amountquartile
C3Suzuki120,0001
C6Watanabe98,0001
C1Tanaka85,0002
C5Ito75,0002
C8Kobayashi55,0003
C2Sato42,0003
C4Takahashi30,0004
C7Nakamura15,0004
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT customer_id, name, total_amount, NTILE(4) OVER(ORDER BY total_amount DESC) AS quartile FROM customers ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM
FROM customersRead all 8 rows from customers. Their order is unspecified at this point.
1 / 3
customer_idnametotal_amount
C1Tanaka85,000
C2Sato42,000
C3Suzuki120,000
C4Takahashi30,000
C5Ito75,000
C6Watanabe98,000
C7Nakamura15,000
C8Kobayashi55,000
8 rows read (order unspecified)
LEARNING POINTS
Distribution when rows do not divide evenly: When the row count is not divisible by the bucket count, extra rows are added one at a time starting with the lower-numbered, higher-ranked buckets. Nine rows divided into four buckets therefore produce 3, 2, 2, and 2 rows: the remainder is one, so only the first bucket has three rows. Remember that the highest-ranked group may be slightly larger.
Combine it with PARTITION BY: 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.
Decile analysis: E-commerce marketing often uses 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.
▶ NTILE distribution with a remainder — 9 rows divided into 4 buckets
ranktotal_amountquartile (NTILE=4)note
1st120,00011 extra row → added to bucket 1
2nd98,0001
3rd90,0001 ← 3 rows⇦ remainder goes here
4th85,0002
5th75,0002 ← 2 rows
6th55,0003
7th42,0003 ← 2 rows
8th30,0004
9th15,0004 ← 2 rows
9 ÷ 4 = 2 remainder 1: only bucket 1 has 3 rows; every other bucket has 2. Extra rows are always distributed from the highest-ranked bucket downward.
ANTI-PATTERNS
Confusing NTILE's quartile number with statistical quartiles: Statistical Q1 through Q3 are values themselves, such as Q1 = 75,000. 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.
Leaving ties indeterminate: If the ORDER BY column contains equal values, NTILE assignments at a bucket boundary are engine-dependent. If two customers each spent 75,000, for example, there is no guarantee which one enters bucket 2 and which enters bucket 3. Add a tiebreaker such as ORDER BY total_amount DESC, customer_id when assignments must be deterministic.
QUESTION 3
ROWS BETWEEN — Define a Window Frame for a Three-Day Moving Average
ROWS BETWEENMoving AverageFrame SpecificationTime-Series Analysis
Background

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
)
Common frame patterns:
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 vs. RANGE: 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.
Problem

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.

Table
▶ daily_temperature
measured_datetemp
2024-07-0128.5
2024-07-0231.2
2024-07-0333.0
2024-07-0429.8
2024-07-0532.1
2024-07-0635.4
Expected Output
measured_datetempmoving_avg_3dcalculation (reference)
2024-07-0128.528.528.5 ÷ 1
2024-07-0231.229.9(28.5+31.2) ÷ 2
2024-07-0333.030.9(28.5+31.2+33.0) ÷ 3
2024-07-0429.831.3(31.2+33.0+29.8) ÷ 3
2024-07-0532.131.6(33.0+29.8+32.1) ÷ 3
2024-07-0635.432.4(29.8+32.1+35.4) ÷ 3
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT measured_date, temp, ROUND( AVG(temp) OVER( ORDER BY measured_date ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ) , 1) AS moving_avg_3d FROM daily_temperature ORDER BY measured_date;
LEGEND
Rows read / loaded
① FROM
FROM daily_temperatureRead all 6 rows from daily_temperature.
1 / 3
measured_datetemp
07-0128.5
07-0231.2
07-0333.0
07-0429.8
07-0532.1
07-0635.4
6 rows read
LEARNING POINTS
Moving averages are a basic smoothing technique: Daily temperatures and sales can fluctuate enough to obscure a trend. Adding a three- or seven-day moving average smooths short-term noise and makes the trend easier to see. This is one of the most common calculation patterns in stock charts and KPI dashboards.
The practical difference between ROWS and RANGE: A bounded RANGE frame is based on differences in ORDER BY values, not on a number of physical rows, and exact syntax for date offsets varies by database. Peer rows with the same date are included together. Use 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.
Combine it with PARTITION BY: 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.
▶ How the ROWS BETWEEN frame slides from row to row
measured_date temp rows in frame (blue = current, dim = preceding) avg
07-0128.5 28.5 28.5
07-0231.2 28.5 + 31.2 29.9
07-0333.0 28.5 + 31.2 + 33.0 30.9
07-0429.8 31.2 + 33.0 + 29.8 ← 07-01 leaves the frame 31.3
Starting at 07-04, 07-01 is outside the aggregation frame. The frame slides downward while retaining the latest three rows.
ANTI-PATTERNS
Misunderstanding the default frame for OVER(ORDER BY): With 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.
Specifying ROWS BETWEEN without ORDER BY: ROWS BETWEEN defines a frame by relative row positions. Without ORDER BY, there is no deterministic basis for which rows are the preceding two. Always use ROWS BETWEEN together with ORDER BY.
QUESTION 4
FIRST_VALUE() / LAST_VALUE() — Attach the Period's Opening and Closing Prices to Every Row
FIRST_VALUELAST_VALUEFrame PitfallStock AnalysisImportant
Background

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
⚠ The critical LAST_VALUE pitfall: Without an explicit whole-partition frame such as 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.
Problem

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.

Table
▶ stock_prices
stocktrade_dateprice
TYK2024-04-011,200
TYK2024-04-021,150
TYK2024-04-031,280
OSK2024-04-01850
OSK2024-04-02920
OSK2024-04-03890
Expected Output
stocktrade_datepricefirst_pricelast_price
OSK2024-04-01850850890
OSK2024-04-02920850890
OSK2024-04-03890850890
TYK2024-04-011,2001,2001,280
TYK2024-04-021,1501,2001,280
TYK2024-04-031,2801,2001,280
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT stock, trade_date, price, FIRST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS first_price, LAST_VALUE(price) OVER( PARTITION BY stock ORDER BY trade_date ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING ) AS last_price FROM stock_prices ORDER BY stock, trade_date;
LEGEND
Rows read / loaded
① FROM
FROM stock_pricesRead all 6 rows from stock_prices.
1 / 4
stocktrade_dateprice
TYK04-011,200
TYK04-021,150
TYK04-031,280
OSK04-01850
OSK04-02920
OSK04-03890
6 rows read
LEARNING POINTS
FIRST_VALUE is relatively safe; LAST_VALUE demands care: FIRST_VALUE still means the first row's value under the usual default frame because the first row remains in the frame. LAST_VALUE, however, sees only the last row in the current frame—under this example's unique ordering values, that is the current row—unless the frame is extended through the partition. This asymmetry causes confusion.
MAX/MIN may be the right alternative: 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 bug — without vs. with an explicit frame (TYK partition)
✗ No explicit frame (bug)
LAST_VALUE(price) OVER(
  PARTITION BY stock
  ORDER BY trade_date
  -- frame omitted!
  -- usual default ends at current peer group
)
trade_datepricelast_price (bug)
04-011,2001,200 ← itself!
04-021,1501,150 ← itself!
04-031,2801,280 ← itself!
! Always equals price. LAST_VALUE is not producing the period's closing price.
✓ Explicit frame (correct)
LAST_VALUE(price) OVER(
  PARTITION BY stock
  ORDER BY trade_date
  ROWS BETWEEN
    UNBOUNDED PRECEDING
    AND UNBOUNDED FOLLOWING
)
trade_datepricelast_price (correct)
04-011,2001,280 ✓
04-021,1501,280 ✓
04-031,2801,280 ✓
✓ The period's closing price, 1,280, is correctly attached to every row.
ANTI-PATTERNS
Omitting the LAST_VALUE frame—the most important pitfall: As shown above, with this example's unique trade dates, LAST_VALUE without an explicit frame returns the current row's value. No error is raised, making this a dangerous silently wrong result. Adopt a team rule that LAST_VALUE used for a partition's final value must include ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Assuming RANGE is always different here: 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.
QUESTION 5
CTE + ROW_NUMBER() — The Production Pattern for Top N per Department
CTEWITHROW_NUMBERTop-N SelectionProduction Essential
Background

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
Top N per group is one of SQL's most common production patterns: CTE + ROW_NUMBER directly solves requests such as the top three sellers per department, the top five items per product category, or the single most recent access per user—any problem asking for Top N within every group. Make this combination second nature.
Problem

From the emp_sales table below, return the top two employees in each department (dept) by descending sales. Implement the query with a CTE.

Table
▶ emp_sales
deptemp_namesales
SalesTanaka850,000
SalesSato720,000
SalesSuzuki930,000
EngineeringTakahashi410,000
EngineeringIto380,000
EngineeringWatanabe450,000
Expected Output
deptemp_namesales
SalesSuzuki930,000
SalesTanaka850,000
EngineeringWatanabe450,000
EngineeringTakahashi410,000
Model Answer
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
  */
Explanation (table transitions & key points)
WITH ranked AS ( SELECT dept, emp_name, sales, ROW_NUMBER() OVER( PARTITION BY dept ORDER BY sales DESC, emp_name ) AS rn FROM emp_sales ) SELECT dept, emp_name, sales FROM ranked WHERE rn <= 2 ORDER BY CASE dept WHEN 'Sales' THEN 1 WHEN 'Engineering' THEN 2 END, sales DESC;
LEGEND
Rows read / loaded
① FROM (inside CTE)
FROM emp_sales (inside CTE ranked)The CTE's inner query runs first and reads all 6 rows from emp_sales.
1 / 4
deptemp_namesales
SalesTanaka850,000
SalesSato720,000
SalesSuzuki930,000
EngineeringTakahashi410,000
EngineeringIto380,000
EngineeringWatanabe450,000
6 rows read
LEARNING POINTS
Why a window function cannot be used directly in WHERE: SQL's logical evaluation order is FROM → WHERE → GROUP BY → HAVING → window functions → SELECT → ORDER BY. Because WHERE runs before window functions, a result such as rn does not yet exist there. One level of nesting in a CTE or subquery makes the result available to the outer query as an ordinary column.
Use RANK/DENSE_RANK when ties must be included: ROW_NUMBER assigns distinct numbers even to equal sales. The added emp_name tiebreaker makes that choice deterministic, but it still keeps only one of two employees tied at the boundary. For "top two ranks including ties," use RANK() OVER(...) AS rn with WHERE rn <= 2; then every employee tied for second is returned.
QUALIFY in BigQuery and Snowflake: Some data warehouses provide a dedicated clause such as 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.
▶ Intermediate CTE table (ranked) — all rows before WHERE filtering
deptemp_namesales rn (ROW_NUMBER)WHERE rn<=2 result
Sales Suzuki930,000 1 ✓ included
Sales Tanaka850,000 2 ✓ included
Sales Sato720,000 3 ✗ excluded
Engineering Watanabe450,000 1 ✓ included
Engineering Takahashi410,000 2 ✓ included
Engineering Ito380,000 3 ✗ excluded
Without the CTE, rn does not yet exist when WHERE is evaluated. Nesting one level with a CTE or subquery makes rn available as an ordinary column to the outer WHERE.
ANTI-PATTERNS
Using a window result directly in WHERE: In most databases, 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.
Trying the same thing in HAVING: HAVING is also evaluated before window functions, so it cannot directly filter their results. Use the outer WHERE of a CTE/subquery, or QUALIFY in a database that supports it.
FIELD NOTE: Top N within a group
CTE + ROW_NUMBER is one of the most frequently used window-function patterns in production. Examples: retrieve only each user's latest login (ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY login_time DESC) AS rnWHERE 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.