ROW_NUMBER / RANK / DENSE_RANK — Compare ranking behavior when scores are tied
There are 3 window functions for ranking, and they handle ties differently. Because the right choice depends on how well it fits the business requirement, it is important to understand the differences precisely.
ROW_NUMBER() OVER (ORDER BY col) -- Always assigns a sequence, even for ties. The ORDER BY sequence matters RANK() OVER (ORDER BY col) -- Ties receive the same rank. The next rank skips by the number of tied rows DENSE_RANK() OVER (ORDER BY col) -- Ties receive the same rank. The next rank is always +1 (no gaps)
From the score_board table, assign each player 3 rankings—ROW_NUMBER, RANK, and DENSE_RANK—in descending score order. Return the columns player_id, score, row_num, rank, dense_rank, ordered by score descending and player_id ascending.
| player_id | score |
|---|---|
| P1 | 980 |
| P2 | 750 |
| P3 | 980 |
| P4 | 620 |
| P5 | 750 |
| P6 | 880 |
| player_id | score | row_num | rank | dense_rank |
|---|---|---|---|---|
| P1 | 980 | 1 | 1 | 1 |
| P3 | 980 | 2 | 1 | 1 |
| P6 | 880 | 3 | 3 | 2 |
| P2 | 750 | 4 | 4 | 3 |
| P5 | 750 | 5 | 4 | 3 |
| P4 | 620 | 6 | 6 | 4 |
SELECT player_id, score, ROW_NUMBER() OVER (ORDER BY score DESC, player_id) AS row_num, -- Sequential even for ties (deterministic by player_id) RANK() OVER (ORDER BY score DESC) AS rank, -- Ties share a rank; the next rank is skipped DENSE_RANK() OVER (ORDER BY score DESC) AS dense_rank -- Ties share a rank; the next rank is consecutive FROM score_board ORDER BY score DESC, player_id; /* Logical SQL evaluation order: 1. FROM score_board → read 6 rows 2. Evaluate window functions → assign ROW_NUMBER/RANK/DENSE_RANK 3. SELECT → select 5 columns 4. ORDER BY score DESC, player_id → final sort */
LEGEND
① FROM score_board — read 6 input rows
FROM score_boardRead all 6 rows from the score_board table. At this point the scores are in arbitrary order. P1 and P3 both have score=980, while P2 and P5 both have score=750, so 2 ties exist.| player_id | ▸ score |
|---|---|
| P1 | 980 |
| P2 | 750 |
| P3 | 980 |
| P4 | 620 |
| P5 | 750 |
| P6 | 880 |
ROW_NUMBER() OVER (ORDER BY score DESC), which of the tied players (P1/P3) becomes 1st may change between executions. Add a unique key such as player_id to ORDER BY to guarantee the same result every time. This is especially important for pagination.RANK() OVER (PARTITION BY department ORDER BY salary DESC) to create rankings within each department. To extract the top person in each department, use WHERE rank = 1 through a subquery or CTE. This is one of the most frequently used ranking patterns in practice.WHERE ROW_NUMBER() OVER () = 1, which row becomes 1st is nondeterministic. Always specify ORDER BY to make the ranking criterion explicit.WHERE rank <= 3, 4 rows are returned when 2 people tie for 3rd. Use ROW_NUMBER when you need exactly N rows; use RANK or DENSE_RANK when you want everyone tied through 3rd place. The choice must match the requirement.WITH ranked AS (SELECT *, RANK() OVER (...) AS r FROM t) SELECT * FROM ranked WHERE r = 1 to handle many practical cases, such as retrieving the maximum-valued row in each group or selecting the latest record during deduplication.LAG / LEAD — Calculate month-over-month growth in a single query
LAG / LEAD are window functions that reference the value N rows before or after the current row. They calculate differences from the previous or next period without a self-join through GROUP BY.
LAG(col, 1) OVER (ORDER BY date_col) -- Value 1 row earlier (default offset=1) LAG(col, 3) OVER (ORDER BY date_col) -- 3 rows earlier (for quarter-over-quarter comparisons, for example) LEAD(col, 1) OVER (ORDER BY date_col) -- Value 1 row later -- Prevent division by zero: NULLIF(val, 0) → returns NULL when val is 0 NULLIF(prev_revenue, 0) -- Returns NULL instead of dividing by 0 (essential in production)
LAG(col, 1, 0).From the monthly_revenue table, calculate monthly revenue, previous-month revenue, and month-over-month growth rate (%). Return the columns month, revenue, prev_revenue, growth_rate_pct (round the growth rate to 2 decimal places), ordered by month ascending. For the first month, both prev_revenue and growth_rate_pct are NULL.
| month | revenue |
|---|---|
| 2024-01 | 1000000 |
| 2024-02 | 1200000 |
| 2024-03 | 1100000 |
| 2024-04 | 1350000 |
| 2024-05 | 1500000 |
| 2024-06 | 1420000 |
| month | revenue | prev_revenue | growth_rate_pct |
|---|---|---|---|
| 2024-01 | 1000000 | NULL | NULL |
| 2024-02 | 1200000 | 1000000 | 20.00 |
| 2024-03 | 1100000 | 1200000 | -8.33 |
| 2024-04 | 1350000 | 1100000 | 22.73 |
| 2024-05 | 1500000 | 1350000 | 11.11 |
| 2024-06 | 1420000 | 1500000 | -5.33 |
WITH lag_data AS ( SELECT month, revenue, LAG(revenue) OVER (ORDER BY month) AS prev_revenue -- Previous month's revenue (NULL on the first row) FROM monthly_revenue ) SELECT month, revenue, prev_revenue, ROUND( 100.0 * (revenue - prev_revenue) / NULLIF(prev_revenue, 0), -- Return NULL when prev_revenue=0 to avoid division by zero 2 ) AS growth_rate_pct FROM lag_data ORDER BY month; /* Logical SQL evaluation order: 1. CTE lag_data → add the previous month's revenue 2. Outer query → calculate month-over-month growth and ROUND it 3. ORDER BY month → ascending month order */
LEGEND
① CTE: FROM monthly_revenue — read monthly revenue data
FROM monthly_revenue (in CTE lag_data)Read all 6 rows from the monthly_revenue table. There is no previous-month data at this stage. In the next step, LAG references the revenue from the preceding row for each row.| month | ▸ revenue |
|---|---|
| 2024-01 | 1000000 |
| 2024-02 | 1200000 |
| 2024-03 | 1100000 |
| 2024-04 | 1350000 |
| 2024-05 | 1500000 |
| 2024-06 | 1420000 |
ROUND(100.0 * (revenue - LAG(revenue) OVER (ORDER BY month)) / NULLIF(LAG(revenue) OVER (ORDER BY month), 0), 2) evaluates LAG twice and significantly reduces readability. When reusing a window-function result, calculate it once in a CTE and then reference it. This is the standard production pattern.LAG(revenue, 1, 0) treats the first row, where no previous month exists, as 0 instead of NULL. However, this makes the growth-rate denominator 0 and causes division by zero, so NULLIF protection is still always required. Clarify whether the requirement calls for leaving NULL, filling 0, or carrying forward the previous value.LEAD(revenue) OVER (ORDER BY month) references the value in the next row (the following month). After joining a budget table with actuals, it is often used in dashboard metrics that calculate the gap between current-month actuals and next-month budget. LAG and LEAD are symmetric concepts, so understanding one lets you use both.(revenue - prev_revenue) / prev_revenue makes PostgreSQL return a division by zero error. Always wrap the denominator in NULLIF(denominator, 0).LAG(revenue) OVER (ORDER BY month) determines processing order within the window, while FROM ... ORDER BY month determines output row order. The calculation is correct if the outer ORDER BY is omitted as long as OVER contains ORDER BY, but output order is not guaranteed. Always specify both explicitly.LAG(revenue, 12) OVER (ORDER BY month) to retrieve revenue from the same month in the previous year. This makes it possible to compare growth while removing seasonal effects such as year-end shopping, and to judge whether a trend is real or seasonal. Production dashboards commonly display both MoM and YoY.Running Totals and Cumulative Ratios — Use SUM OVER for Pareto analysis of sales
A running total is the sum accumulated from the first row through the current row, implemented with a window-frame specification. Combining it with a cumulative ratio (%) lets you check the 80:20 rule (the Pareto principle) in a single query.
SUM(col) OVER ( ORDER BY col DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- Sum from the first row through the current row ) → Running total for each row SUM(col) OVER () → Grand total of all rows (without PARTITION BY)
RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (value-based). When multiple rows have the same value, RANGE includes all tied rows in the frame, which changes the running-total result. Always specify ROWS BETWEEN for a running total.From the category_sales table, calculate a running total and cumulative ratio (%) in descending sales order. Calculate the running total and grand total in a CTE, then calculate the ratio in the outer query. Return category, sales_amount, cum_sales, cum_pct (round cum_pct to 2 decimal places), ordered by sales_amount descending.
| category | sales_amount |
|---|---|
| Electronics | 4800000 |
| Apparel | 3200000 |
| Food | 2100000 |
| Books | 1400000 |
| Others | 900000 |
| category | sales_amount | cum_sales | cum_pct |
|---|---|---|---|
| Electronics | 4800000 | 4800000 | 38.71 |
| Apparel | 3200000 | 8000000 | 64.52 |
| Food | 2100000 | 10100000 | 81.45 |
| Books | 1400000 | 11500000 | 92.74 |
| Others | 900000 | 12400000 | 100.00 |
WITH running AS ( SELECT category, sales_amount, SUM(sales_amount) OVER ( ORDER BY sales_amount DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW -- Running total from the first row through the current row ) AS cum_sales, SUM(sales_amount) OVER () AS grand_total -- No frame → total of all rows FROM category_sales ) SELECT category, sales_amount, cum_sales, ROUND(100.0 * cum_sales / grand_total, 2) AS cum_pct -- Calculate the cumulative ratio (%) FROM running ORDER BY sales_amount DESC; /* Logical SQL evaluation order: 1. CTE running 2. Outer query 3. ORDER BY sales_amount DESC → sales descending */
LEGEND
① CTE: FROM category_sales — read category sales data
FROM category_sales (in CTE running)Read all 5 rows from the category_sales table. The next step adds a running total to each row with SUM OVER. The grand total is 4,800,000+3,200,000+2,100,000+1,400,000+900,000 = 12,400,000.| category | ▸ sales_amount |
|---|---|
| Electronics | 4800000 |
| Apparel | 3200000 |
| Food | 2100000 |
| Books | 1400000 |
| Others | 900000 |
SUM(col) OVER () specifies neither PARTITION BY, ORDER BY, nor a frame, the sum of all rows is copied to every row. This technique calculates each row's share of a group total in one query. Adding PARTITION BY extends it to shares within each department.WHERE cum_pct <= 80 retrieves only the rows within the Pareto line. This pattern applies to inventory management (key items), customer segments (key customers), and bug management (high-impact bugs).SUM(sales_amount) OVER () and SUM(sales_amount) OVER (ORDER BY sales_amount DESC) are completely different. Omitting ORDER BY copies the grand total (the same value as grand_total) to every row; it does not produce a running total. Always specify ORDER BY and ROWS BETWEEN for a running total.100.0 * cum_sales / grand_total, or cast with cum_sales::numeric / grand_total * 100.CASE WHEN cum_pct <= 70 THEN 'A' WHEN cum_pct <= 90 THEN 'B' ELSE 'C' END AS abc_rank to complete the ABC classification. This single SQL pattern supports inventory-optimization decisions such as tightly managing stockout risk for A items and reducing ordering frequency for C items.NTILE — Classify user purchasing segments by quartile
NTILE(n) assigns each row a bucket number (1–n) after ordering the rows with ORDER BY. It divides the rows into n groups by frequency, which is fundamentally different from CASE WHEN with fixed thresholds.
NTILE(4) OVER (ORDER BY col DESC) -- Order all rows from high to low and divide them into 4 groups. If the row count is not a multiple of 4, distribute the remainder to the higher buckets -- Example: divide 9 rows by 4 → bucket 1 has 3 rows, buckets 2–4 have 2 rows each ← distribute the remainder one row at a time from the front -- Difference from WIDTH_BUCKET (see the basic set) WIDTH_BUCKET: equal-width buckets (intervals have the same width; row counts may be uneven) NTILE: equal-frequency buckets (row counts are even; interval widths may be uneven)
Classify purchase amounts in the user_spending table into quartiles with NTILE(4) in descending order, and assign the segment labels VIP / Gold / Silver / Bronze. Return user_id, total_spend, quartile, segment, ordered by total_spend descending.
| user_id | total_spend |
|---|---|
| U01 | 15000 |
| U02 | 8500 |
| U03 | 52000 |
| U04 | 3200 |
| U05 | 28000 |
| U06 | 6400 |
| U07 | 41000 |
| U08 | 1800 |
| user_id | total_spend | quartile | segment |
|---|---|---|---|
| U03 | 52000 | 1 | VIP |
| U07 | 41000 | 1 | VIP |
| U05 | 28000 | 2 | Gold |
| U01 | 15000 | 2 | Gold |
| U02 | 8500 | 3 | Silver |
| U06 | 6400 | 3 | Silver |
| U04 | 3200 | 4 | Bronze |
| U08 | 1800 | 4 | Bronze |
WITH ranked AS ( SELECT user_id, total_spend, NTILE(4) OVER (ORDER BY total_spend DESC) AS quartile -- Divide into 4 groups from the top FROM user_spending ) SELECT user_id, total_spend, quartile, CASE quartile WHEN 1 THEN 'VIP' WHEN 2 THEN 'Gold' WHEN 3 THEN 'Silver' ELSE 'Bronze' END AS segment FROM ranked ORDER BY total_spend DESC; /* Logical SQL evaluation order: 1. CTE ranked 2. Outer query 3. ORDER BY total_spend DESC → amount descending */
LEGEND
① CTE: FROM user_spending — read user purchase data
FROM user_spending (in CTE ranked)Read all 8 rows from the user_spending table. total_spend is widely distributed, from 1,800 to 52,000.| user_id | ▸ total_spend |
|---|---|
| U01 | 15000 |
| U02 | 8500 |
| U03 | 52000 |
| U04 | 3200 |
| U05 | 28000 |
| U06 | 6400 |
| U07 | 41000 |
| U08 | 1800 |
PERCENTILE_DISC(0.25), covered in Basic Q2, returns the value at the 1st-quartile threshold, whereas NTILE(4) returns the bucket number showing which quartile each row belongs to. Use PERCENTILE_DISC when you need the Q1 threshold, and NTILE when you need to classify each user into Q1–Q4. This distinction is standard in practice.GROUP BY segment plus aggregate functions calculates each segment's average, minimum, and maximum purchase amount in one query. For example: SELECT segment, COUNT(*), AVG(total_spend), MIN(total_spend), MAX(total_spend) FROM ranked GROUP BY segment ORDER BY MIN(total_spend) DESC. The NTILE → GROUP BY aggregation pattern is a standard customer-analysis technique.CASE WHEN total_spend >= 30000 THEN 'VIP' WHEN total_spend >= 10000 THEN 'Gold' ... concentrate rows in particular buckets when the data is skewed, such as when only 1 user spends a large amount. Use NTILE whenever the business requirement is to equalize row counts.NTILE(4) OVER (), the row-processing order is undefined and bucket numbers may change between executions. Always specify ORDER BY for NTILE. If ORDER BY is not unique because many values tie, add a unique key such as player_id to stabilize the result.NTILE(10) OVER (ORDER BY total_spend DESC) to classify users from the top 10% (Decile 1) to the bottom 10% (Decile 10), then analyze how many times the average purchase amount of Decile 1 exceeds the overall average. E-commerce sites can turn this directly into tiered initiatives, such as exclusive offers for Decile 1 and churn-prevention measures for Deciles 8–10. Changing NTILE(4) to NTILE(10) immediately implements the pattern.CORR — Analyze investment efficiency statistically with the correlation between ad spend and revenue
The correlation coefficient (CORR) expresses the strength of a linear relationship between 2 variables as a value from -1 to 1. PostgreSQL's CORR(y, x) function returns the sample correlation coefficient.
CORR(y, x) -- Sample correlation coefficient (-1 to 1): strength and direction of a linear relationship COVAR_SAMP(y, x) -- Sample covariance (divided by n-1): “corresponds to the numerator” of CORR COVAR_POP(y, x) -- Population covariance (divided by n) -- Internal calculation of CORR: -- CORR(y, x) = COVAR_SAMP(y, x) / (STDDEV_SAMP(x) * STDDEV_SAMP(y))
From the ad_performance table, calculate the correlation coefficient between ad spend (ad_spend) and revenue, each mean, and the number of data points in 1 row. Return corr_spend_revenue, avg_spend, avg_revenue, data_points (round the correlation coefficient to 2 decimal places and each mean to an integer).
| channel | ad_spend | revenue |
|---|---|---|
| Search | 500000 | 2000000 |
| Social | 300000 | 2000000 |
| Display | 100000 | 500000 |
| Video | 400000 | 1800000 |
| 200000 | 700000 |
| corr_spend_revenue | avg_spend | avg_revenue | data_points |
|---|---|---|---|
| 0.88 | 300000 | 1400000 | 5 |
SELECT ROUND(CORR(revenue, ad_spend)::numeric, 2) AS corr_spend_revenue, -- Correlation coefficient (-1 to 1) ROUND(AVG(ad_spend)) AS avg_spend, -- Average ad spend (rounded to an integer) ROUND(AVG(revenue)) AS avg_revenue, -- Average revenue (rounded to an integer) COUNT(*) AS data_points -- Number of data points FROM ad_performance; /* Logical SQL evaluation order: 1. FROM ad_performance → read 5 rows 2. CORR(revenue, ad_spend) → calculate the correlation coefficient 3. AVG(ad_spend) / AVG(revenue) → calculate the means 4. COUNT(*) → aggregate the row count 5. SELECT → output 1 row */
LEGEND
① FROM ad_performance — read channel-level advertising data
FROM ad_performanceRead all 5 rows from the ad_performance table. Search (500K→2,000K) and Video (400K→1,800K) have high ad spend and high revenue. Display (100K→500K) has low ad spend and low revenue.| channel | ▸ ad_spend | ▸ revenue |
|---|---|---|
| Search | 500000 | 2000000 |
| Social | 300000 | 2000000 |
| Display | 100000 | 500000 |
| Video | 400000 | 1800000 |
| 200000 | 700000 |
CORR(y, x) = COVAR_SAMP(y, x) / (STDDEV_SAMP(x) × STDDEV_SAMP(y)). In practice, it is also important to inspect COVAR_SAMP(revenue, ad_spend) to see the covariance (the average of deviation products) and work backward to determine which channel is pushing CORR upward. Even CORR=0.88 can be distorted by an outlier in 1 channel.ROUND(CORR(...)::numeric, 2), or you get a type error (the same applies to STDDEV and AVG). This is a common error pattern in practice, so make a habit of adding the ::numeric cast when combining aggregate functions with ROUND.CORR(revenue, search_spend), CORR(revenue, social_spend), CORR(revenue, display_spend) gives an overview of which channel is most correlated with revenue. However, multicollinearity (correlation between channels) makes interpretation more complex, so it is common in practice to combine this with advanced statistical methods such as multiple regression and MMM (Marketing Mix Modeling). SQL's CORR is the first step in exploratory data analysis (EDA).