SQL Window Functions — Applied Frame Clauses and Top-N

ADVWindow FunctionsNTILE / PERCENT_RANKWindow FramesMoM / Growth RateTop-N per GroupPostgreSQL/MySQL 8.0+/BigQuery5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

NTILE() — Divide Rows into N Rank Groups

NTILEORDER BYQuantile GroupsEven Distribution
Background

NTILE(N) divides all rows into N groups as evenly as possible and assigns each row a group number from 1 to N. It is useful for quantile-based groups such as “customers in the top 25%” or “products in the bottom 10% by score.”

SELECT
  name, score,
  NTILE(4) OVER(ORDER BY score DESC) AS quartile
  -- Sort by score descending and divide all rows into four groups; the top 25% has quartile=1
FROM students;
When the rows do not divide evenly: The remainder is distributed one extra row at a time starting with the lowest-numbered group. For example, NTILE(4) over six rows gives two rows each to groups 1 and 2, and one row each to groups 3 and 4.
Problem

From the sales_scores table below, return each sales representative’s name, score, and group number (tier) after dividing the rows into three groups by descending score.

The highest-scoring group is tier=1, and the lowest-scoring group is tier=3.

Input Table
▸ sales_scores
sales_repscore
Alex95
Blair80
Casey65
Drew50
Emery35
Flynn20
Expected Output
sales_repscoretier
Alex951
Blair801
Casey652
Drew502
Emery353
Flynn203
QUESTION 2

ROWS BETWEEN — Calculate a Moving Average with a Window Frame

ROWS BETWEENORDER BYMoving AverageWindow Frame
Background

A frame clause inside a window function’s OVER() defines which range of rows contributes to the calculation for each current row.

AVG(revenue) OVER(
  ORDER BY sale_date
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
  -- Calculate over the current row and the two preceding rows (up to three rows total)
)
Common frame specifications:
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: first row through current row (running aggregate)
ROWS BETWEEN 2 PRECEDING AND CURRENT ROW: two preceding rows plus current row (three-row moving average)
• With ORDER BY but no frame clause, the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (running aggregate)
Problem

From the daily_sales table below, return each date’s revenue and its three-day moving average (moving_avg_3d).

Use at most three rows—the current row and two preceding rows—and display the average to one decimal place.
For the opening rows with fewer than three days of data, average only the rows that exist.

Input Table
▸ daily_sales
sale_daterevenue
04-01100
04-02200
04-03150
04-04300
04-05250
Expected Output
sale_daterevenuemoving_avg_3d
04-01100100.0
04-02200150.0
04-03150150.0
04-04300216.7
04-05250233.3
QUESTION 3

PERCENT_RANK() — Calculate Percentile Rank Within a Group

PERCENT_RANKPARTITION BYPercentile RankRelative Position
Background

PERCENT_RANK() returns a value from 0.0 to 1.0 that represents a row’s relative rank within its group. The similar CUME_DIST() instead returns the number of rows at or below the current value divided by the total row count, so its minimum is not zero.

PERCENT_RANK() OVER(
  PARTITION BY subject
  ORDER BY score ASC
) AS pct_rank
How the value is calculated: For a group with multiple rows, the formula is (rank - 1) / (row count - 1). With ascending order, the lowest score is 0.00 and a unique highest score is 1.00. Tied highest scores can remain below 1.00 because they share a rank. A one-row group returns 0.00.
Problem

From the test_scores table below, return each student’s name, subject, score, and percentile rank within the subject (pct_rank).

Calculate each subject independently in ascending score order and display the 0.0–1.0 result to two decimal places.

Input Table
▸ test_scores
student_namesubjectscore
AlexMathematics90
BlairMathematics75
CaseyMathematics60
DrewEnglish85
EmeryEnglish70
Expected Output
student_namesubjectscorepct_rank
CaseyMathematics600.00
BlairMathematics750.50
AlexMathematics901.00
EmeryEnglish700.00
DrewEnglish851.00
QUESTION 4

LAG() + CTE — Calculate Month-over-Month Growth

LAGCTEGrowth RateMoM Analysis
Background

Combining the preceding row returned by LAG() with the current value lets you calculate rates of change such as month-over-month growth.

Growth-rate formula: (current month − previous month) ÷ previous month × 100

Repeating LAG() several times in the SELECT list can duplicate window-expression work and can hurt performance. Computing LAG once in a CTE (WITH clause) and calculating the growth rate in the outer query keeps the expression reusable and readable.

WITH base AS (
  SELECT sort_col, num_col,
    LAG(num_col) OVER(ORDER BY sort_col) AS prev_val
    -- Evaluate LAG once in the CTE and reuse it in the outer query
  FROM table_name
)
SELECT *, (num_col - prev_val) * 100.0 / NULLIF(prev_val, 0) AS rate_pct
FROM base;
Division by zero and NULL: When the preceding value used as the denominator is 0, the division raises an error, so the usual guard is to turn the denominator into NULL with NULLIF. On the first row there is no preceding row, so LAG returns NULL and anything computed from it is NULL too.
Problem

From the monthly_revenue table below, return each month, revenue, previous month’s revenue (prev_revenue), and month-over-month growth percentage (growth_rate_pct).

Display the growth rate to one decimal place. NULL is expected for the first row, which has no previous month.

Input Table
▸ monthly_revenue
monthrevenue
2024-011000
2024-021200
2024-031100
2024-041500
Expected Output
monthrevenueprev_revenuegrowth_rate_pct
2024-011000NULLNULL
2024-021200100020.0
2024-0311001200-8.3
2024-041500110036.4
QUESTION 5

ROW_NUMBER() + Subquery — Return the Top N Rows per Category

ROW_NUMBERPARTITION BYTop-NProduction Pattern
Background

A window-function result such as a row number cannot be filtered in WHERE at the same query level. WHERE is evaluated before window functions, so use a subquery or CTE first.

-- ✗ Error: a window-function result cannot be used directly in WHERE
SELECT * FROM t
WHERE ROW_NUMBER() OVER(...) <= 2;  -- SQL Error!

-- ✓ Wrap the calculation in a subquery, then filter in WHERE
SELECT * FROM (
  SELECT *, ROW_NUMBER() OVER(...) AS rn FROM t
) ranked
WHERE rn <= 2;
Why a tie-breaker is required: ROW_NUMBER() always assigns distinct numbers to tied rows, but which of them comes first is undefined while the ordering stays equal. Append a unique column to ORDER BY so that the rows selected are deterministic.
Problem

From the product_sales table below, return only the top two products by sales amount in each category.

Use ROW_NUMBER(), not DENSE_RANK, to assign a unique row number when amounts tie within a category. Break ties by product name so the selected rows are deterministic.

Input Table
▸ product_sales
categoryproductamount
BeveragesCoffee4000
BeveragesTea2800
BeveragesJuice1500
FoodApple3000
FoodBanana2500
FoodGrapes1800
Expected Output
categoryproductamount
BeveragesCoffee4000
BeveragesTea2800
FoodApple3000
FoodBanana2500