Window Functions — Learn NTILE, PERCENT_RANK, CTEs, LAG + LEAD, and Moving Averages in Practice
AdvancedWindow FunctionsNTILE / PERCENT_RANKCTEsLAG + LEADPartitioned Moving AveragesPostgreSQL / MySQL 8.0+ / BigQuery5 questions
QUESTION 6
NTILE() — Segment Users into Quartiles by Purchase Amount
NTILENumbering FunctionSegmentation
Background

NTILE(N) divides all rows into up to N groups whose sizes differ by at most one row (buckets) and assigns each row a group number in the range 1 through N.
It belongs to the same family of numbering functions as ROW_NUMBER, RANK, and DENSE_RANK.

NTILE(4) OVER(ORDER BY total_purchase DESC)
-- Sort by purchase amount descending and divide into four groups
-- 1 = highest-value group (top 25%)
-- 4 = lowest-value group (bottom 25%)
When the rows do not divide evenly: If 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, six rows divided into four groups gives two rows each to tiers 1 and 2, and one row each to tiers 3 and 4.
Problem

Using the customers table below, classify every user into four tiers in descending order of total purchase amount (total_purchase).
Divide the rows so tier 1 is the highest-value group and tier 4 is the lowest-value group.

Input table
▸ customers
user_idnametotal_purchase
U1Tanaka48000
U2Suzuki12000
U3Sato95000
U4Takahashi33000
U5Ito71000
U6Watanabe8000
Expected Output
user_idnametotal_purchasetier
U3Sato950001
U5Ito710001
U1Tanaka480002
U4Takahashi330002
U2Suzuki120003
U6Watanabe80004

Six rows divided into four groups gives a quotient of 1 and a remainder of 2. The first two groups (tiers 1 and 2) receive two rows each; the remaining groups (tiers 3 and 4) receive one row each.

QUESTION 7
PERCENT_RANK() — Calculate a Score's Position in the Overall Distribution
PERCENT_RANKRelative RankPercentile
Background

PERCENT_RANK() returns each row's relative position within the full set as a decimal from 0.0 to 1.0.
For a set with multiple rows, the minimum row is 0.0 (0th percentile) and the maximum row is 1.0 (100th percentile). A single-row set returns 0.0.

PERCENT_RANK() OVER(ORDER BY score)
-- Formula: (rank - 1) / (total_rows - 1)
-- Example: rank 3 of 5 → (3-1)/(5-1) = 0.5 = 50th percentile
Difference from CUME_DIST: CUME_DIST() returns the proportion of rows whose value is less than or equal to the current row, producing a value greater than 0 and at most 1. PERCENT_RANK starts at 0 by normalizing rank as (rank - 1) / (total_rows - 1). Their values differ even without ties; when ties occur, the difference in how ranks are handled becomes even more important.
Problem

Using the exam_scores table below, calculate the percentile position of each employee's test score within the full set.
Display the result as a percentage with one decimal place and order the output by score ascending.

Input table
▸ exam_scores
employee_idnamescore
E1Tanaka70
E2Suzuki90
E3Sato60
E4Takahashi85
E5Ito75
Expected Output
namescorepercentile
Sato600.0%
Tanaka7025.0%
Ito7550.0%
Takahashi8575.0%
Suzuki90100.0%

PERCENT_RANK = (rank-1)/(n-1). With five rows, the denominator is 4. Tanaka has rank 2, so (2-1)/4 = 0.25 → 25.0%.

QUESTION 8
WITH Clause (CTE) — Modularize Window Functions for Running Totals and Shares
CTE / WITH ClauseSUM OVERRunning Total / ShareReadability
Background

Writing a long query containing window functions as nested subqueries can make it difficult to read. A WITH clause (CTE: Common Table Expression) provides a modern top-to-bottom structure: first build an intermediate table with window functions, then use it for further calculations.

WITH cte_name AS (
  -- ① Build an intermediate table with a window function
  SELECT ..., SUM(col) OVER(...) AS running_total
  FROM tbl
)
-- ② Reference the intermediate table for further calculations
SELECT ..., running_total / total * 100 AS pct
FROM cte_name;
Meaning of an empty OVER(): When nothing is specified inside OVER, as in SUM(revenue) OVER(), the function aggregates across all rows, producing the table-wide total. This lets one CTE calculate both the grand total and the running total.
Problem

Using the monthly_revenue table below and a WITH clause, output each month's revenue together with the following two calculated values.
1. running_total — cumulative revenue from January
2. monthly_pct — that month's share of total revenue (percent, one decimal place).

Input table
▸ monthly_revenue
monthrevenue
Jan200
Feb250
Mar180
Apr320
May290
Expected Output
monthrevenuerunning_totalmonthly_pct
Jan20020016.1%
Feb25045020.2%
Mar18063014.5%
Apr32095025.8%
May290124023.4%

Grand total = 200 + 250 + 180 + 320 + 290 = 1240. Jan: 200 ÷ 1240 × 100 = 16.1%.

QUESTION 9
LAG() + LEAD() Together — Automatically Detect Peak Days by Comparing Adjacent Values
LAG + LEADAdjacent ComparisonCASE WHENAnomaly Detection
Background

Using LAG (the previous row) and LEAD (the next row) together enables a three-point comparison with adjacent values. This is a common time-series pattern for detecting peaks (local maxima), valleys (local minima), and sudden changes.

LAG(amount)  OVER(ORDER BY date)  AS prev_amount  -- Value from one row earlier
LEAD(amount) OVER(ORDER BY date)  AS next_amount  -- Value from one row later

-- Compare three points with CASE WHEN → detect a peak
CASE
  WHEN amount > prev_amount AND amount > next_amount THEN 'PEAK'
END
Handling the first and last rows: LAG returns NULL for the first row, and LEAD returns NULL for the last row. A comparison with NULL (> NULL) is UNKNOWN; because CASE WHEN does not treat UNKNOWN as true, the first and last rows are automatically excluded from peak detection. This is the intended behavior.
Problem

Using the daily_sales table below, output the previous day's sales as prev_amount and the next day's sales as next_amount beside each date. In the is_peak column, output 'PEAK' when sales exceed both adjacent days; otherwise output the empty string ''.

Input table
▸ daily_sales
dateamount
04-0180
04-02160
04-03110
04-04190
04-05130
04-06175
Expected Output
dateamountprev_amountnext_amountis_peak
04-0180NULL160
04-0216080110PEAK
04-03110160190
04-04190110130PEAK
04-05130190175
04-06175130NULL

Apr-02: 160 > 80 and 160 > 110 → PEAK. Apr-06: because LEAD is NULL, the comparison is UNKNOWN and does not satisfy CASE WHEN → not PEAK.

QUESTION 10
PARTITION BY × Frame Clause — Calculate an Independent Moving Average per Store
PARTITION BYROWS BETWEENMoving Average by GroupCommon in Practice
Background

Combining PARTITION BY (group partitioning) with ROWS BETWEEN (frame specification) creates a window that moves only within each group. This is one of the most practical window-function patterns.

AVG(amount) OVER(
  PARTITION BY store_id       -- Separate the window by store
  ORDER BY date               -- Order by date within each partition
  ROWS BETWEEN 1 PRECEDING AND CURRENT ROW
  -- One preceding row + current row = two-day average
)
The reset effect of PARTITION BY: Each time the partition changes, the frame always resets and starts from the first row of the new partition. This automatically prevents cross-partition contamination from accidentally including another store's rows.
Problem

Using the store_daily_sales table below, calculate an independent moving average over the latest two rows (moving_avg_2d) for each store (store_id).
The window must reset whenever the store changes.

Input table
▸ store_daily_sales
store_iddateamount
S104-01100
S104-02140
S104-03120
S104-04160
S204-01200
S204-02180
S204-03220
S204-04190
Expected Output
store_iddateamountmoving_avg_2d
S104-01100100.0
S104-02140120.0
S104-03120130.0
S104-04160140.0
S204-01200200.0
S204-02180190.0
S204-03220200.0
S204-04190205.0

S2 on Apr-01 is the first row in the S2 partition, so its average uses only one row and equals 200.0. The value from S1 on Apr-04 is not carried over.