Window Functions — Learn NTILE, Frames, PERCENT_RANK, Month-over-Month Growth, and Top-N in Practice
AdvancedWindow FunctionsNTILE / PERCENT_RANKWindow FramesMoM / Growth RateTop-N per GroupPostgreSQL/MySQL 8.0+/BigQuery5 questions
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 row count is not divisible by N, the remainder is distributed one extra row at a time starting with the first 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.

Formula: (rank - 1) ÷ (number of rows in the group - 1)

• The lowest-ranked row is always 0.00. A unique highest-ranked row reaches 1.00; tied highest scores can produce a value below 1.00 because they share a rank.
• A one-row group returns 0.00, as databases define the result as zero when the formula’s denominator would be zero.
• The related CUME_DIST() calculates “rows at or below the current value ÷ total rows,” so it never returns zero.

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 month, revenue,
    LAG(revenue) OVER(ORDER BY month) AS prev_revenue
    -- Evaluate LAG once in the CTE and reuse it in the outer query
  FROM monthly_revenue
)
SELECT *, (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0) AS growth_rate
FROM base;
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;
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