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.
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.
| customer_id | total_amount |
|---|---|
| C1 | 50000 |
| C2 | 40000 |
| C3 | 30000 |
| C4 | 20000 |
| C5 | 10000 |
| customer_id | total_amount | tier |
|---|---|---|
| C1 | 50000 | 1 |
| C2 | 40000 | 1 |
| C3 | 30000 | 2 |
| C4 | 20000 | 2 |
| C5 | 10000 | 3 |
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.
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 */
LEGEND
① 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.| customer_id | total_amount |
|---|---|
| C1 | 50000 |
| C2 | 40000 |
| C3 | 30000 |
| C4 | 20000 |
| C5 | 10000 |
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.PERCENT_RANK or RANK and implement the grouping logic explicitly instead of using NTILE.| customer_id | total_amount | tier |
|---|---|---|
| C1 | 50000 | 1 |
| C2 | 40000 | 1 |
| C3 | 30000 | 2 |
| C4 | 20000 | 2 |
| C5 | 10000 | 3 |
All values differ, so no tie crosses a boundary.
| customer_id | total_amount | tier |
|---|---|---|
| C1 | 50000 | 1 |
| C2 | 30000 | 1 |
| C3 | 30000 | 2 |
| C4 | 10000 | 2 |
C2 and C3 both have 30,000 but are split between tiers 1 and 2.
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%.
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.
| student_id | score |
|---|---|
| S1 | 95 |
| S2 | 88 |
| S3 | 75 |
| S4 | 60 |
| student_id | score | pct_rank |
|---|---|---|
| S1 | 95 | 0 |
| S2 | 88 | 0.3333 |
| S3 | 75 | 0.6666 |
| S4 | 60 | 1 |
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....
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 */
LEGEND
① FROM
FROM exam_scoresRead the table. It contains scores for four students.| student_id | score |
|---|---|
| S1 | 95 |
| S2 | 88 |
| S3 | 75 |
| S4 | 60 |
WHERE pct_rank <= 0.2. Whether the population has 100 or 10,000 users, the same query dynamically selects the top 20%.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.| student_id | score | RANK | PERCENT_RANK (RANK−1) / (N−1) | CUME_DIST RANK / N |
|---|---|---|---|---|
| S1 | 95 | 1st | 0.000 ← always starts at 0 | 0.250 |
| S2 | 88 | 2nd | 0.333 | 0.500 |
| S3 | 75 | 3rd | 0.667 | 0.750 |
| S4 | 60 | 4th | 1.000 ← always ends at 1 | 1.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.
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 )
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.
| month | sales |
|---|---|
| 2026-01 | 10 |
| 2026-02 | 20 |
| 2026-03 | 30 |
| 2026-04 | 40 |
| month | sales | prev_running_total |
|---|---|---|
| 2026-01 | 10 | NULL |
| 2026-02 | 20 | 10 |
| 2026-03 | 30 | 30 |
| 2026-04 | 40 | 60 |
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 */
LEGEND
① FROM
FROM monthly_targetsRead the table containing four months of sales data.| month | sales |
|---|---|
| 2026-01 | 10 |
| 2026-02 | 20 |
| 2026-03 | 30 |
| 2026-04 | 40 |
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.| ROWS BETWEEN … AND … | Aggregation range | Typical use |
|---|---|---|
UNBOUNDED PRECEDING to CURRENT ROW | First row through current row (inclusive) | Running sales or count |
UNBOUNDED PRECEDING to 1 PRECEDING | First row through prior row (current excluded) | Total through prior period |
1 PRECEDING to 1 FOLLOWING | One row before and after (3 rows total) | Three-period moving average |
UNBOUNDED PRECEDING to UNBOUNDED FOLLOWING | Entire partition | Attach group total to every row (like OVER without ORDER BY) |
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.
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.
| sale_date | amount |
|---|---|
| 04-01 | 10 |
| 04-02 | 20 |
| 04-02 | 30 |
| 04-03 | 40 |
| sale_date | amount | range_total (implicit) | rows_total (explicit) |
|---|---|---|---|
| 04-01 | 10 | 10 | 10 |
| 04-02 | 20 | 60 | 30 |
| 04-02 | 30 | 60 | 60 |
| 04-03 | 40 | 100 | 100 |
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.
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 */
LEGEND
① 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.| sale_date | amount |
|---|---|
| 04-01 | 10 |
| 04-02 | 20 |
| 04-02 | 30 |
| 04-03 | 40 |
ORDER BY date, id or specify ROWS explicitly.| sale_date | amount | Rows in frame | range_total |
|---|---|---|---|
| 04-01 | 10 | [04-01 only] | 10 |
| 04-02 | 20 | [04-01, 04-02 × 2 rows] ← peer group | 60 |
| 04-02 | 30 | [04-01, 04-02 × 2 rows] ← peer group | 60 |
| 04-03 | 40 | [04-01 through 04-03all] | 100 |
The two same-date rows are peers, so their combined total of 60 is assigned to both.
| sale_date | amount | Rows in frame | rows_total |
|---|---|---|---|
| 04-01 | 10 | [04-01 only] | 10 |
| 04-02 | 20 | [04-01, current row(20)] | 30 |
| 04-02 | 30 | [04-01, 20, current row(30)] | 60 |
| 04-03 | 40 | [04-01 through 04-03all] | 100 |
Counting each row independently gives the same-date rows different totals: 30 and 60.
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.| Goal | Recommended expression | Reason |
|---|---|---|
| Running total at end of day | ORDER BY date only (default RANGE) | Naturally groups same-day rows |
| Exact total after each row | Explicit ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW | Guarantees independent row processing |
| Moving average | ROWS BETWEEN N-1 PRECEDING AND CURRENT ROW | Ties cannot expand the frame unexpectedly |
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.
For every order row in the order_history table below, add these three metrics.
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)
| user_id | order_date | amount |
|---|---|---|
| U1 | 04-01 | 1000 |
| U2 | 04-02 | 1500 |
| U1 | 04-05 | 2000 |
| U1 | 04-10 | 3000 |
| user_id | order_date | amount | order_num | running_amt | prev_date |
|---|---|---|---|---|---|
| U1 | 04-01 | 1000 | 1 | 1000 | NULL |
| U1 | 04-05 | 2000 | 2 | 3000 | 04-01 |
| U1 | 04-10 | 3000 | 3 | 6000 | 04-05 |
| U2 | 04-02 | 1500 | 1 | 1500 | NULL |
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 */
LEGEND
① FROM
FROM order_historyRead four rows in insertion order, with U1 and U2 interleaved. No grouping or order has been established yet.| user_id | order_date | amount |
|---|---|---|
| U1 | 04-01 | 1000 |
| U2 | 04-02 | 1500 |
| U1 | 04-05 | 2000 |
| U1 | 04-10 | 3000 |
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.| Function | Metric added | First-row value | Frame-dependent |
|---|---|---|---|
ROW_NUMBER() | order_num (order number) | Always starts at 1 | No (row number only) |
SUM(amount) | running_amt (cumulative purchase amount) | First order amount only | Yes (implicit default from first through current row) |
LAG(order_date) | prev_date (previous purchase date) | NULL (no preceding row) | No (row reference only) |
| Analysis | Additional query |
|---|---|
| Average days to second purchase | WHERE order_num = 2 → AVG(DATEDIFF(order_date, prev_date)) |
| Order number that first exceeds 5,000 cumulative | WHERE running_amt >= 5000 → MIN(order_num) per user |
| Select first purchases only | WHERE order_num = 1 |
| Average repurchase interval by user | AVG(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.