SQL Window Functions — Applied PERCENT_RANK and LAG+LEAD

ADVWindow FunctionsNTILE / PERCENT_RANKCTEsLAG + LEADPartitioned Moving AveragesPostgreSQL / MySQL 8.0+ / BigQuery5 questions
1 / 5 · In progress 0 / 5 completed
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
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
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
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
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