Statistical Analysis — Learn Ranking, period comparisons, cumulative analysis, and correlation coefficients in Practice
AdvancedStatistics AnalysisRANK / LAG / LEADRunning Totals / NTILECORRPostgreSQL Compatible5 questions
QUESTION 1
ROW_NUMBER / RANK / DENSE_RANK — Compare ranking behavior when scores are tied
ROW_NUMBERRANKDENSE_RANKRanking AnalysisTie Handling
Background

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)
Which one should you use: Choose DENSE_RANK when you need consecutive ranks (for example, display 1st, 2nd, and 3rd), ROW_NUMBER when you need each row's position in the full result (for example, pagination or deduplication), and RANK when gaps in the ranking carry business meaning (for example, Olympic standings).
Problem

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.

Source table
▸ score_board (6 rows)
player_idscore
P1980
P2750
P3980
P4620
P5750
P6880
Expected Output

Expected output (score descending, player_id ascending):

player_idscorerow_numrankdense_rank
P1980111
P3980211
P6880332
P2750443
P5750543
P4620664

P1 and P3 are tied at 980: RANK → both 1st (skips to 3rd next) / DENSE_RANK → both 1st (2nd next) / ROW_NUMBER → sequential 1, 2 (determined by ascending player_id)

QUESTION 2
LAG / LEAD — Calculate month-over-month growth in a single query
LAGLEADPeriod ComparisonsMoM GrowthNULLIF
Background

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)
NULL on the first and last rows: LAG returns NULL for the first row, where no previous row exists. LEAD returns NULL for the last row, where no next row exists. Whether NULL should be treated as 0% growth or excluded depends on the requirement. You can also specify a default value as the 3rd argument: LAG(col, 1, 0).
Problem

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.

Source table
▸ monthly_revenue (6 rows)
monthrevenue
2024-011000000
2024-021200000
2024-031100000
2024-041350000
2024-051500000
2024-061420000
Expected Output

Expected output (month ascending):

monthrevenueprev_revenuegrowth_rate_pct
2024-011000000NULLNULL
2024-021200000100000020.00
2024-0311000001200000-8.33
2024-041350000110000022.73
2024-051500000135000011.11
2024-0614200001500000-5.33

Growth rate = (current month − previous month) / previous month × 100. 2024-03 is -8.33% versus the previous month's 1,200,000 (down 100,000) / 2024-06 is -5.33% month over month (down 80,000)

QUESTION 3
Running Totals and Cumulative Ratios — Use SUM OVER for Pareto analysis of sales
SUM OVERUNBOUNDED PRECEDINGRunning TotalPareto AnalysisCumulative Ratio
Background

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)
The trap of ROWS BETWEEN versus the default frame: If you omit the frame specification from an OVER clause with ORDER BY, the default is 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.
Problem

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.

Source table
▸ category_sales (5 rows)
categorysales_amount
Electronics4800000
Apparel3200000
Food2100000
Books1400000
Others900000
Expected Output

Expected output (sales_amount descending):

categorysales_amountcum_salescum_pct
Electronics4800000480000038.71
Apparel3200000800000064.52
Food21000001010000081.45
Books14000001150000092.74
Others90000012400000100.00

Grand total=12,400,000 / Electronics + Apparel = 64.52%, so the top 2 categories account for two-thirds of sales. Including Food, the top 3 categories account for 81.45% (a distribution close to the Pareto principle).

QUESTION 4
NTILE — Classify user purchasing segments by quartile
NTILECASE WHENSegment AnalysisQuartilesCTE
Background

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 Basic Q4)
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)
Label NTILE results with CASE WHEN: In practice, it is standard to convert the bucket number into business terminology (1=VIP, 2=Gold, and so on) rather than display the number directly. Calculate NTILE in a CTE and then apply CASE WHEN to avoid duplicating the same OVER clause.
Problem

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.

Source table
▸ user_spending (8 rows)
user_idtotal_spend
U0115000
U028500
U0352000
U043200
U0528000
U066400
U0741000
U081800
Expected Output

Expected output (total_spend descending):

user_idtotal_spendquartilesegment
U03520001VIP
U07410001VIP
U05280002Gold
U01150002Gold
U0285003Silver
U0664003Silver
U0432004Bronze
U0818004Bronze

8 rows divided into 4 equal groups → 2 rows per bucket. VIP: U03(52000)/U07(41000) / Gold: U05(28000)/U01(15000) / Silver: U02(8500)/U06(6400) / Bronze: U04(3200)/U08(1800)

QUESTION 5
CORR — Analyze investment efficiency statistically with the correlation between ad spend and revenue
CORRCOVAR_SAMPCorrelation AnalysisCorrelation CoefficientStatistical Testing
Background

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))
Guidelines for interpreting the correlation coefficient: |r| ≥ 0.9 → very strong / |r| 0.7–0.9 → strong / |r| 0.5–0.7 → moderate / |r| < 0.5 → weak. However, a correlation coefficient measures only the strength of a linear relationship. Always check for nonlinear relationships and the influence of outliers. It is also important that “correlation does not imply causation” (a third factor may be present).
Problem

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).

Source table
▸ ad_performance (5 rows)
channelad_spendrevenue
Search5000002000000
Social3000002000000
Display100000500000
Video4000001800000
Email200000700000
Expected Output

Expected output (1 row):

corr_spend_revenueavg_spendavg_revenuedata_points
0.8830000014000005

CORR ≈ 0.879 (strong positive correlation) / avg_spend: (500K+300K+100K+400K+200K)/5=300K / avg_revenue: (2000K+2000K+500K+1800K+700K)/5=1400K / Note: Social has revenue=2000K with ad spend=300K (higher ROI than the others)