Window Functions — Learn Analytical Functions and Data Mart Design from the Basics
BasicWindow FunctionsAnalytics & AggregationData Mart DesignPostgreSQL/MySQL 8.0+/BigQuery5 questions
QUESTION 6
NTILE() — Divide Data Evenly into N Groups
NTILEORDER BYRFM Analysis / Ranking
Background

NTILE(N) divides sorted data as evenly as possible into N buckets (groups) and assigns each row a number from 1 through N.
It is especially useful for classifying customers by purchase amount into a top tier (1), middle tier (2), and bottom tier (3), as in RFM analysis.

Problem

From the customer_sales table below, divide customers into three groups (1, 2, 3) in descending order of sales (total_amount) and output the group number as tier.

Source Table
▸ customer_sales
customer_idtotal_amount
C150000
C240000
C330000
C420000
C510000
Expected Output
customer_idtotal_amounttier
C1500001
C2400001
C3300002
C4200002
C5100003

When 5 rows are split into 3 groups, the remainder is assigned starting with the earlier groups. Groups 1 and 2 therefore contain 2 rows each, while group 3 contains 1 row.

Model Answer
SELECT
  customer_id,
  total_amount,
  NTILE(3) OVER(ORDER BY total_amount DESC) AS tier
FROM customer_sales                                  -- Sort by sales descending and divide all rows evenly into three groups
ORDER BY total_amount DESC;

/*
  Execution order:
  1. FROM customer_sales (all 5 rows)
  2. ORDER BY total_amount DESC sorts rows by sales descending
  3. NTILE(3) divides the rows into three buckets
  4. SELECT returns the result
  */
Explanation (table transitions & key points)
SELECT customer_id, total_amount, NTILE(3) OVER(ORDER BY total_amount DESC) AS tier FROM customer_sales ORDER BY total_amount DESC;
LEGEND
Rows read / loaded
① FROM
FROM customer_salesRead the table. It contains data for five customers. Because NTILE() assigns buckets according to row order, the next step must sort the rows with ORDER BY.
1 / 4
customer_idtotal_amount
C150000
C240000
C330000
C420000
C510000
5 rows read
LEARNING POINTS
Use in analysis: NTILE(10) instantly creates groups for decile analysis, while NTILE(4) creates quartiles. It is often used to build datasets that compare behavior between the top 10% and bottom 10% of customers.
ANTI-PATTERNS
Ties crossing a boundary: NTILE divides mechanically by row count, so customers with the same sales can fall on opposite sides of a boundary and end up in different groups. If equal values must always share a group, use PERCENT_RANK or RANK and implement the grouping logic explicitly instead of using NTILE.
Example of a tie crossing a boundary (splitting 4 rows into 2 groups separates equal amounts)
NTILE(3) — 5 rows, 3 groups (no issue)
customer_idtotal_amounttier
C1500001
C2400001
C3300002
C4200002
C5100003

All values differ, so no tie crosses a boundary.

✗ NTILE(2) — Equal amounts of 30,000 split across groups
customer_idtotal_amounttier
C1500001
C2300001
C3300002
C4100002

C2 and C3 both have 30,000 but are split between tiers 1 and 2.

QUESTION 7
PERCENT_RANK() — Measure Relative Rank within the Whole Set
PERCENT_RANKORDER BYPercentiles
Background

PERCENT_RANK() calculates a row's relative position from 0.0 (first place) to 1.0 (last place).
Its formula is (the row's RANK - 1) / (total row count - 1). It is useful for requirements such as selecting only high-performing rows in the top 20%.

Problem

From the exam_scores table below, calculate each student's score and the relative position of that score within the whole set as pct_rank.

Source Table
▸ exam_scores
student_idscore
S195
S288
S375
S460
Expected Output
student_idscorepct_rank
S1950
S2880.3333
S3750.6666
S4601

The top row (rank 1) is 0, and the bottom row (rank 4) is 1. For S2, (rank 2 - 1) / (4 students - 1) = 1/3 = 0.333....

Model Answer
SELECT
  student_id,
  score,
  PERCENT_RANK() OVER(ORDER BY score DESC) AS pct_rank  -- Relative rank from the top (0 to 1)
FROM exam_scores
ORDER BY score DESC;

/*
  Execution order:
  1. FROM exam_scores (all 4 rows)
  2. ORDER BY score DESC sorts descending and establishes ranks
  3. PERCENT_RANK() calculates (RANK - 1) / (4 - 1) for each row
  4. SELECT returns the result
  */
Explanation (table transitions & key points)
SELECT student_id, score, PERCENT_RANK() OVER(ORDER BY score DESC) AS pct_rank FROM exam_scores ORDER BY score DESC;
LEGEND
Rows read / loaded
① FROM
FROM exam_scoresRead the table. It contains scores for four students.
1 / 4
student_idscore
S195
S288
S375
S460
4 rows read
LEARNING POINTS
Filtering in production: To select users in the top 20%, combine this result with a subquery (or CTE) and specify WHERE pct_rank <= 0.2. Whether the population has 100 or 10,000 users, the same query dynamically selects the top 20%.
Difference from CUME_DIST(): A similar function is CUME_DIST(), which calculates cumulative distribution. PERCENT_RANK assigns 0 to rank 1, whereas CUME_DIST starts at 1/N for the first-ranked row and always ends at 1. Choose according to the requirement.
☷ Numerical comparison of PERCENT_RANK and CUME_DIST (four students)
student_idscoreRANKPERCENT_RANK
(RANK−1) / (N−1)
CUME_DIST
RANK / N
S1951st0.000 ← always starts at 00.250
S2882nd0.3330.500
S3753rd0.6670.750
S4604th1.000 ← always ends at 11.000

PERCENT_RANK is intuitive for filtering the top N%: because it starts at 0, <= 0.2 means the top 20%. CUME_DIST is better for expressing the cumulative proportion of rows at or below a value.

QUESTION 8
Advanced ROWS BETWEEN — Prior Running Total Excluding the Current Row
Frame SpecificationPRECEDINGPrior Running Total
Background

A ROWS BETWEEN frame can define flexible aggregation ranges, including not only “from the first row through the current row,” but also “from the first row through the preceding row,” which excludes the current row.

SUM(sales) OVER(
  ORDER BY month
  ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
)
Problem

From the monthly_targets table below, calculate each month's sales and the running total through the previous month as prev_running_total.

For the first month (2026-01), no prior-month total exists, so the result is NULL.

Source Table
▸ monthly_targets
monthsales
2026-0110
2026-0220
2026-0330
2026-0440
Expected Output
monthsalesprev_running_total
2026-0110NULL
2026-022010
2026-033030
2026-044060
Model Answer
SELECT
  month,
  sales,
  SUM(sales) OVER(
    ORDER BY month
    ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
  ) AS prev_running_total  -- From the first row through the preceding row; exclude the current row
FROM monthly_targets
ORDER BY month;

/*
  Frame evaluation:
  2026-01: no preceding row, so the frame is empty → NULL
  2026-02: sum of [2026-01 only] → 10
  2026-03: sum of [2026-01, 2026-02] → 10 + 20 = 30
  2026-04: sum of [2026-01, 2026-02, 2026-03] → 10 + 20 + 30 = 60
*/
Explanation (table transitions & key points)
SELECT month, sales, SUM(sales) OVER( ORDER BY month ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING ) AS prev_running_total FROM monthly_targets ORDER BY month;
LEGEND
Rows read / loaded
① FROM
FROM monthly_targetsRead the table containing four months of sales data.
1 / 4
monthsales
2026-0110
2026-0220
2026-0330
2026-0440
4 rows read
LEARNING POINTS
Production use case: This frame is useful in complex plan-versus-actual dashboards—for example, recalculating the current month's target by combining current sales with the shortfall carried forward from prior months.
Shifting a frame: An end of CURRENT ROW (the default) includes the current row; 1 PRECEDING totals through the prior row; and 1 FOLLOWING includes the next row. The ability to move both frame boundaries freely is the core power of frame specifications.
☷ Common frame patterns at a glance
ROWS BETWEEN … AND …Aggregation rangeTypical use
UNBOUNDED PRECEDING to CURRENT ROWFirst row through current row (inclusive)Running sales or count
UNBOUNDED PRECEDING to 1 PRECEDINGFirst row through prior row (current excluded)Total through prior period
1 PRECEDING to 1 FOLLOWINGOne row before and after (3 rows total)Three-period moving average
UNBOUNDED PRECEDING to UNBOUNDED FOLLOWINGEntire partitionAttach group total to every row (like OVER without ORDER BY)
QUESTION 9
RANGE vs ROWS — The Running-Total Trap with Duplicate Dates
RANGEROWSHandling TiesCritical
Background

When ORDER BY is specified and the frame clause is omitted, RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW applies by default.
ROWS operates on physical rows, while RANGE operates on logical values. Their behavior therefore differs decisively when the sort key contains ties, such as duplicate dates.

Problem

The sales_records table below contains two rows dated 04-02.
Output both the default running total with only ORDER BY (equivalent to RANGE) and a running total with ROWS BETWEEN... specified explicitly, then compare their behavior for rows on the same date.

Source Table
▸ sales_records
sale_dateamount
04-0110
04-0220
04-0230
04-0340
Expected Output
sale_dateamountrange_total (implicit)rows_total (explicit)
04-01101010
04-02206030
04-02306060
04-0340100100

With implicit RANGE, the values 20 and 30 on 04-02 are summed together and 60 is assigned to both rows. ROWS accumulates each physical row separately: 10+20=30, then 10+20+30=60.

Model Answer
SELECT
  sale_date,
  amount,
  SUM(amount) OVER(                                   -- Omitted frame (implicit RANGE BETWEEN...)
    ORDER BY sale_date
  ) AS range_total,
  SUM(amount) OVER(                                   -- Specify ROWS explicitly
    ORDER BY sale_date
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS rows_total

FROM sales_records
ORDER BY sale_date, amount;

/*
  [RANGE] Evaluate by value.
  At a 04-02 row, sum every row whose date is at or before 04-02.
  The two 04-02 rows form a peer group,
  (10 + 20 + 30 = 60) and the result is assigned to both rows.

  [ROWS] Evaluate by physical row.
  04-02(20) row: [10, 20] = 30
  04-02(30) row: [10, 20, 30] = 60
*/
Explanation (table transitions & key points)
SELECT sale_date, amount, SUM(amount) OVER(ORDER BY sale_date) AS range_total, SUM(amount) OVER(ORDER BY sale_date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS rows_total FROM sales_records ORDER BY sale_date, amount;
LEGEND
Rows read / loaded
① FROM
FROM sales_recordsRead the table. Notice that two rows have the date 04-02. Multiple rows sharing the same value is what distinguishes RANGE behavior from ROWS behavior.
1 / 4
sale_dateamount
04-0110
04-0220
04-0230
04-0340
4 rows read (two dated 04-02)
LEARNING POINTS
Production choice:Use default RANGE when you want cumulative sales as of the end of each day, because it groups same-day sales. For an exact running amount after every order, add a unique key such as ORDER BY date, id or specify ROWS explicitly.
❖ Peer groups: how RANGE handles ties
RANGE: when evaluating a 04-02 row, group same-date rows as peers
sale_dateamountRows in framerange_total
04-0110[04-01 only]10
04-0220[04-01, 04-02 × 2 rows] ← peer group60
04-0230[04-01, 04-02 × 2 rows] ← peer group60
04-0340[04-01 through 04-03all]100

The two same-date rows are peers, so their combined total of 60 is assigned to both.

ROWS: process physical row positions independently even for the same date
sale_dateamountRows in framerows_total
04-0110[04-01 only]10
04-0220[04-01, current row(20)]30
04-0230[04-01, 20, current row(30)]60
04-0340[04-01 through 04-03all]100

Counting each row independently gives the same-date rows different totals: 30 and 60.

ANTI-PATTERNS
Accidentally using RANGE: Writing only ORDER BY date for a moving average implicitly selects RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, producing a puzzling cumulative average through that date instead. Always choose the frame deliberately.
✓ Decision guide
GoalRecommended expressionReason
Running total at end of dayORDER BY date only (default RANGE)Naturally groups same-day rows
Exact total after each rowExplicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWGuarantees independent row processing
Moving averageROWS BETWEEN N-1 PRECEDING AND CURRENT ROWTies cannot expand the frame unexpectedly
QUESTION 10
Practical Capstone — Track Customer Purchases (Count, Total, and Previous Purchase)
CapstoneData MartProduction ReportingCombined
Background

In production data platforms, building a data mart often means enriching each event row with the customer's status at that point in time.
Combine ROW_NUMBER(), SUM(), and LAG() to build a powerful tracking query.

Problem

For every order row in the order_history table below, add these three metrics.

1. order_num: which numbered order this is for the user (ROW_NUMBER)
2. running_amt: the user's cumulative purchase amount through that point (SUM)
3. prev_date: the user's previous purchase date (LAG; NULL for the first order)
Source Table
▸ order_history
user_idorder_dateamount
U104-011000
U204-021500
U104-052000
U104-103000
Expected Output
user_idorder_dateamountorder_numrunning_amtprev_date
U104-01100011000NULL
U104-0520002300004-01
U104-1030003600004-05
U204-02150011500NULL
Model Answer
SELECT
  user_id,
  order_date,
  amount,
  ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_date) AS order_num,    -- ① Order number (sequence by date)
  SUM(amount) OVER(PARTITION BY user_id ORDER BY order_date) AS running_amt,   -- ② Cumulative purchase amount through this point
  LAG(order_date) OVER(PARTITION BY user_id ORDER BY order_date) AS prev_date  -- ③ Previous purchase date

FROM order_history
ORDER BY user_id, order_date;

/*
  Execution order:
  1. FROM order_history (U1 and U2 rows are interleaved)
  2. PARTITION BY user_id divides rows into U1 and U2 groups
  3. The three window functions are evaluated over the same partition and order
  4. ORDER BY user_id, order_date sorts the final output
  */
Explanation (table transitions & key points)
SELECT user_id, order_date, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY order_date) AS order_num, SUM(amount) OVER(PARTITION BY user_id ORDER BY order_date) AS running_amt, LAG(order_date) OVER(PARTITION BY user_id ORDER BY order_date) AS prev_date FROM order_history ORDER BY user_id, order_date;
LEGEND
Rows read / loaded
① FROM
FROM order_historyRead four rows in insertion order, with U1 and U2 interleaved. No grouping or order has been established yet.
1 / 6
user_idorder_dateamount
U104-011000
U204-021500
U104-052000
U104-103000
4 rows read (insertion order, interleaved)
LEARNING POINTS
Remove duplication with WINDOW:Modern SQL can define WINDOW w AS (PARTITION BY user_id ORDER BY order_date) once and use SUM(amount) OVER w in SELECT, avoiding repeated window specifications under the DRY principle.
A foundation of data engineering:For CRM analysis, this table makes advanced questions—such as average days to a second purchase or churn after exceeding 5000 in cumulative spend—answerable with a simple GROUP BY. Window functions are powerful tools for untangling business logic.
☷ Roles and behavior of the three functions
FunctionMetric addedFirst-row valueFrame-dependent
ROW_NUMBER()order_num (order number)Always starts at 1No (row number only)
SUM(amount)running_amt (cumulative purchase amount)First order amount onlyYes (implicit default from first through current row)
LAG(order_date)prev_date (previous purchase date)NULL (no preceding row)No (row reference only)
❖ Further analysis enabled by this data mart (using only GROUP BY)
AnalysisAdditional query
Average days to second purchaseWHERE order_num = 2 → AVG(DATEDIFF(order_date, prev_date))
Order number that first exceeds 5,000 cumulativeWHERE running_amt >= 5000 → MIN(order_num) per user
Select first purchases onlyWHERE order_num = 1
Average repurchase interval by userAVG(DATEDIFF(order_date, prev_date)) GROUP BY user_id

A data mart created by one window-function query reduces later complex analysis to simple GROUP BY and WHERE operations. This is why window functions are foundational to data engineering.