CORR(Y, X) is an aggregate function that returns the Pearson product-moment correlation coefficient between two variables. Its value ranges from −1 to +1: values closer to +1 indicate a strong positive linear correlation, values closer to −1 indicate a strong negative linear correlation, and 0 means no linear correlation.
-- Syntax: CORR(Y, X) ← Because correlation is symmetric, argument order does not affect the result CORR(revenue, ad_cost) -- Formula (Pearson product-moment correlation coefficient): -- r = Σ(xi−x̄)(yi−ȳ) / √[ Σ(xi−x̄)² × Σ(yi−ȳ)² ] -- Numerator: sum of covariance terms (positive → positive correlation, negative → negative correlation) -- Denominator: product of the standard deviations of both variables (scale normalization → bounded by −1 to +1)
GROUP BY category calculates correlations for each category at once. However, it does not support an OVER clause (use as a window function). CORR also returns double precision, so a ::numeric cast is required when combining it with ROUND.Using the ad_spend table, calculate the Pearson correlation coefficient between advertising cost (ad_cost) and revenue (revenue), along with the number of rows included in the calculation (n), in one row. The output columns should be corr_coef (to 4 decimal places), n.
| week | ad_cost | revenue |
|---|---|---|
| 1 | 100 | 280 |
| 2 | 200 | 650 |
| 3 | 300 | 780 |
| 4 | 400 | 1100 |
| 5 | 500 | 1250 |
| 6 | 600 | 1640 |
Expected output (1 row):
| corr_coef | n |
|---|---|
| 0.9914 | 6 |
x̄=350, ȳ=950 / numerator Σ(xi−x̄)(yi−ȳ)=446,000 / denominator √(175,000×1,156,400)=449,856 / CORR=446,000÷449,856≈0.9914 — a very strong positive linear correlation.
SELECT ROUND(CORR(revenue, ad_cost)::numeric, 4) AS corr_coef, -- Pearson correlation coefficient (−1 to +1) COUNT(*) AS n -- Number of rows included in the calculation FROM ad_spend; /* Logical SQL evaluation order: 1. FROM ad_spend → read 6 rows 2. CORR(revenue, ad_cost) → calculate the correlation coefficient 3. COUNT(*) → count the rows 4. ROUND(...::numeric, 4) → round to 4 decimal places 5. SELECT → output 1 row */
LEGEND
① FROM ad_spend — six weeks of advertising-cost and revenue data
FROM ad_spendRead all 6 rows from the ad_spend table. The CORR function quantifies the linear relationship between ad_cost (explanatory variable X) and revenue (response variable Y). Rows containing NULL are automatically excluded.| week | ▸ ad_cost | ▸ revenue |
|---|---|---|
| 1 | 100 | 280 |
| 2 | 200 | 650 |
| 3 | 300 | 780 |
| 4 | 400 | 1100 |
| 5 | 500 | 1250 |
| 6 | 600 | 1640 |
GROUP BY channel to CORR(revenue, ad_cost) to compare correlation coefficients for social, TV, and search advertising in one query. Large differences between channels can indicate that advertising effectiveness depends on the genre, providing a useful insight.LAG(ad_cost,1) OVER (ORDER BY week) to detect delayed effects on the following week's revenue.PostgreSQL's REGR_* function family completes simple linear regression using SQL. It models the relationship between response variable Y and explanatory variable X as Y = slope × X + intercept.
REGR_SLOPE(Y, X) -- Slope: Σ(xi−x̄)(yi−ȳ) / Σ(xi−x̄)² REGR_INTERCEPT(Y, X) -- Y-intercept: ȳ − slope × x̄ REGR_R2(Y, X) -- Coefficient of determination R²: CORR(Y,X)² — shows model fit on a 0–1 scale REGR_COUNT(Y, X) -- Number of valid samples excluding NULL -- Calculate a predicted value (reuse slope and intercept from the CTE): (slope * 700 + intercept)::numeric
Using the same ad_spend table as Q6, calculate the slope, intercept, coefficient of determination (r_squared), and predicted revenue (predicted_700) when ad_cost = 700 in one row. Round slope to 4 decimal places, intercept to 2 decimal places, r_squared to 4 decimal places, and predicted_700 to the nearest integer.
| week | ad_cost | revenue |
|---|---|---|
| 1 | 100 | 280 |
| 2 | 200 | 650 |
| 3 | 300 | 780 |
| 4 | 400 | 1100 |
| 5 | 500 | 1250 |
| 6 | 600 | 1640 |
Expected output (1 row):
| slope | intercept | r_squared | predicted_700 |
|---|---|---|---|
| 2.5486 | 58.00 | 0.9829 | 1842 |
slope=446,000÷175,000=2.5486 / intercept=950−2.5486×350=58.00 / R²=0.9914²=0.9829 / prediction=2.5486×700+58.00=1842.02≈1842
WITH stats AS ( -- Calculate regression parameters together with REGR_* functions (reuse slope and intercept in the CTE) SELECT REGR_SLOPE(revenue, ad_cost) AS slope, REGR_INTERCEPT(revenue, ad_cost) AS intercept, REGR_R2(revenue, ad_cost) AS r_squared FROM ad_spend ) SELECT ROUND(slope::numeric, 4) AS slope, ROUND(intercept::numeric, 2) AS intercept, ROUND(r_squared::numeric, 4) AS r_squared, ROUND((slope * 700 + intercept)::numeric, 0) AS predicted_700 -- predicted value for ad_cost=700 from the regression equation FROM stats; /* Logical SQL evaluation order: 1. CTE stats — FROM ad_spend → read 6 rows 2. REGR_SLOPE(...) → calculate the regression slope 3. REGR_INTERCEPT(...) → calculate the intercept 4. REGR_R2(...) → calculate the coefficient of determination 5. FROM stats → reference the 1-row CTE result 6. predicted_700 → calculate the predicted value 7. Apply each ROUND → round the results 8. SELECT → output 1 row */
LEGEND
① FROM ad_spend — build a regression model from the same data as Q6
FROM ad_spendQ6 established a strong positive correlation of CORR=0.9914. Build the regression equation Y=slope×X+intercept from this data to predict revenue from advertising cost. x̄=350, ȳ=950.| week | ▸ ad_cost (X) | ▸ revenue (Y) |
|---|---|---|
| 1 | 100 | 280 |
| 2 | 200 | 650 |
| 3 | 300 | 780 |
| 4 | 400 | 1100 |
| 5 | 500 | 1250 |
| 6 | 600 | 1640 |
REGR_COUNT(Y, X) returns the number of valid rows after excluding rows where either Y or X is NULL. A large difference between COUNT(*) and REGR_COUNT means that many NULLs may be reducing model reliability.GROUP BY quarter, enabling advanced prediction such as using separate “summer” and “winter” models for categories with seasonal patterns.There are three common types of ranking with window functions, and their behavior differs when values are tied. PARTITION BY assigns independent rankings within each group.
RANK() OVER (PARTITION BY region ORDER BY sales DESC) -- Tied rows share a rank / the next rank is skipped: 1,1,3 (2 is skipped) DENSE_RANK() OVER (PARTITION BY region ORDER BY sales DESC) -- Tied rows share a rank / the next rank is not skipped: 1,1,2 (consecutive) ROW_NUMBER() OVER (PARTITION BY region ORDER BY sales DESC) -- Always consecutive / different numbers even for ties (tie order is nondeterministic): 1,2,3
Using the sales_rep table, assign three descending rankings—RANK, DENSE_RANK, and ROW_NUMBER—within each region based on sales (sales_amount). The output columns should be rep_id, region, sales_amount, rank_val, dense_rank_val, row_num, ordered by region, rank_val, rep_id.
| rep_id | region | sales_amount |
|---|---|---|
| 1 | East | 8500 |
| 2 | East | 6200 |
| 3 | East | 8500 |
| 4 | West | 9100 |
| 5 | West | 7800 |
| 6 | West | 5500 |
| 7 | North | 7200 |
| 8 | North | 7200 |
| 9 | North | 4300 |
Expected output (9 rows, ascending by region, rank_val, and rep_id):
| rep_id | region | sales_amount | rank_val | dense_rank_val | row_num |
|---|---|---|---|---|---|
| 1 | East | 8500 | 1 | 1 | 1 |
| 3 | East | 8500 | 1 | 1 | 2 |
| 2 | East | 6200 | 3 | 2 | 3 |
| 7 | North | 7200 | 1 | 1 | 1 |
| 8 | North | 7200 | 1 | 1 | 2 |
| 9 | North | 4300 | 3 | 2 | 3 |
| 4 | West | 9100 | 1 | 1 | 1 |
| 5 | West | 7800 | 2 | 2 | 2 |
| 6 | West | 5500 | 3 | 3 | 3 |
East/North: two tied rows have rank_val=1, so the next rank is 3 (2 is skipped), while dense_rank_val continues with 2. West has no ties, so all three functions agree. ROW_NUMBER tie order is stabilized with ORDER BY rep_id.
SELECT rep_id, region, sales_amount, RANK() OVER (PARTITION BY region ORDER BY sales_amount DESC) AS rank_val, DENSE_RANK() OVER (PARTITION BY region ORDER BY sales_amount DESC) AS dense_rank_val, ROW_NUMBER() OVER (PARTITION BY region ORDER BY sales_amount DESC, rep_id ASC) AS row_num -- ties stabilized by rep_id FROM sales_rep ORDER BY region, rank_val, rep_id; /* Logical SQL evaluation order: 1. FROM sales_rep → read 9 rows 2. PARTITION BY region → split into 3 groups 3. ORDER BY sales_amount DESC → sort within each group 4. RANK() → ties share a rank; the next rank is skipped 5. DENSE_RANK() → ties share a rank; no ranks are skipped 6. ROW_NUMBER() → assign consecutive numbers 7. ORDER BY region, rank_val, rep_id → apply the final sort 8. SELECT → output 9 rows */
LEGEND
① FROM sales_rep — sales for 9 representatives across 3 regions
FROM sales_repRead sales data for three representatives in each of East, West, and North. East (reps 1 and 3: tied at 8500) and North (reps 7 and 8: tied at 7200) contain ties, making the behavioral differences between the three ranking functions clear.| rep_id | ▸ region | ▸ sales_amount |
|---|---|---|
| 1 | East | 8500 |
| 2 | East | 6200 |
| 3 | East | 8500 |
| 4 | West | 9100 |
| 5 | West | 7800 |
| 6 | West | 5500 |
| 7 | North | 7200 |
| 8 | North | 7200 |
| 9 | North | 4300 |
WHERE dense_rank_val <= N). For pagination, deduplication, and a fixed number of rows → ROW_NUMBER (eliminate nondeterminism by adding a tie-breaker).WHERE row_num = 1 returns only one row and loses tied representatives. To retrieve everyone tied for first place, use WHERE rank_val = 1.WHERE rank_val <= 3 makes the number of returned rows unpredictable (more than 3 if several representatives tie for third). To guarantee exactly N rows, use ROW_NUMBER with a tie-breaker.RANK() OVER (ORDER BY sales_amount DESC) without PARTITION BY for a company-wide ranking, and place it alongside a regional ranking with PARTITION BY in the same query to produce composite KPIs such as “third company-wide and first in the region”.NTILE(n) is a window function that divides rows into n groups and assigns each row a bucket number (1–n). It is useful for RFM analysis, user segmentation, and percentile grouping.
NTILE(4) OVER (ORDER BY total_spent DESC) -- Divide 8 rows into 4 groups → 2 rows per group -- quartile=1: top 25% by spending (VIP) -- quartile=4: bottom 25% by spending (light users) -- When the row count is not divisible by n: -- add one extra row at a time starting with the upper buckets (e.g., 9 rows ÷ 4 → only bucket 1 has 3 rows)
Divide total_spent in the user_spend table into four groups in descending order, and assign each user a quantile number (quartile: 1–4) and segment name (segment: VIP / Heavy / Middle / Light). Assign the buckets in a CTE, generate the segment name in the outer query, and return the rows ordered by quartile, total_spent DESC.
| user_id | total_spent |
|---|---|
| 1 | 250 |
| 2 | 1800 |
| 3 | 420 |
| 4 | 3200 |
| 5 | 890 |
| 6 | 5600 |
| 7 | 150 |
| 8 | 2100 |
Expected output (8 rows, ordered by quartile and total_spent DESC):
| user_id | total_spent | quartile | segment |
|---|---|---|---|
| 6 | 5600 | 1 | VIP |
| 4 | 3200 | 1 | VIP |
| 8 | 2100 | 2 | Heavy |
| 2 | 1800 | 2 | Heavy |
| 5 | 890 | 3 | Middle |
| 3 | 420 | 3 | Middle |
| 1 | 250 | 4 | Light |
| 7 | 150 | 4 | Light |
8 rows ÷ 4 = 2 rows per group. In descending order, the top 2 rows are quartile=1 (VIP), the next 2 are quartile=2 (Heavy), and so on.
WITH segmented AS ( -- Divide spending into 4 descending buckets with NTILE(4) (1 = top 25%) SELECT user_id, total_spent, NTILE(4) OVER (ORDER BY total_spent DESC) AS quartile FROM user_spend ) SELECT user_id, total_spent, quartile, CASE quartile WHEN 1 THEN 'VIP' WHEN 2 THEN 'Heavy' WHEN 3 THEN 'Middle' WHEN 4 THEN 'Light' END AS segment FROM segmented ORDER BY quartile, total_spent DESC; /* Logical SQL evaluation order: 1. CTE segmented — FROM user_spend → read 8 rows 2. NTILE(4) OVER (...) → assign the 4 quartiles 3. Outer SELECT FROM segmented → reference the CTE 4. CASE quartile → convert the number to a business label 5. ORDER BY quartile, total_spent DESC → apply the final sort 6. SELECT → output 8 rows */
LEGEND
① FROM user_spend — total spending for 8 users (unordered)
FROM user_spendRead total_spent for 8 users. The rows are unordered at this point. NTILE(4) divides users into four groups by spending from highest to lowest and classifies them as VIP, Heavy, Middle, or Light.| user_id | ▸ total_spent |
|---|---|
| 1 | 250 |
| 2 | 1800 |
| 3 | 420 |
| 4 | 3200 |
| 5 | 890 |
| 6 | 5600 |
| 7 | 150 |
| 8 | 2100 |
GROUP BY quartile to calculate AVG(total_spent) and COUNT for each tier and understand “how many times larger the average spend of the VIP tier is than the Light tier.”NTILE(5) OVER (ORDER BY last_order_date DESC) AS r_score (more recent orders receive higher scores). Converting this score into segment names and connecting it to a CRM tool or advertising platform completes a practical user-segmentation workflow.LAG(col, offset, default) is a window function that returns the value from offset rows before the current row. It is essential for time-series comparisons such as month-over-month and year-over-year comparisons, and is simpler to write than a self JOIN.
LAG(col, 1) OVER (ORDER BY month) -- Value one row earlier (previous month) LAG(col, 12) OVER (ORDER BY month) -- Value twelve rows earlier (same month last year) LEAD(col, 1) OVER (ORDER BY month) -- Value one row later (next month) -- Calculate month-over-month growth (MoM): -- (current-month revenue − previous-month revenue) / previous-month revenue × 100 -- The first row has no previous month, so it becomes NULL (normal behavior)
prev_revenue = LAG(revenue, 1) OVER (...) in a CTE, then write (revenue - prev_revenue) / prev_revenue in the outer query.Using the monthly_revenue table, calculate each month's revenue, previous-month revenue (prev_revenue), and month-over-month growth rate (mom_growth_pct, to 2 decimal places). First add the previous-month value with LAG in a CTE, then calculate the growth rate in the outer query. It is acceptable for the first month's prev_revenue and mom_growth_pct to be NULL.
| month | revenue |
|---|---|
| 2024-01 | 1200000 |
| 2024-02 | 1350000 |
| 2024-03 | 1180000 |
| 2024-04 | 1520000 |
| 2024-05 | 1680000 |
| 2024-06 | 1450000 |
Expected output (6 rows, ascending by month):
| month | revenue | prev_revenue | mom_growth_pct |
|---|---|---|---|
| 2024-01 | 1200000 | NULL | NULL |
| 2024-02 | 1350000 | 1200000 | 12.50 |
| 2024-03 | 1180000 | 1350000 | -12.59 |
| 2024-04 | 1520000 | 1180000 | 28.81 |
| 2024-05 | 1680000 | 1520000 | 10.53 |
| 2024-06 | 1450000 | 1680000 | -13.69 |
February: (1,350K−1,200K)÷1,200K×100=12.50 / March: (1,180K−1,350K)÷1,350K×100=−12.59 / April: 28.81 / May: 10.53 / June: −13.69
WITH lagged AS ( -- Place the previous month's revenue on the current row with LAG (establish it in the CTE for reuse in the outer query) SELECT month, revenue, LAG(revenue, 1) OVER (ORDER BY month) AS prev_revenue FROM monthly_revenue ) SELECT month, revenue, prev_revenue, ROUND( (revenue - prev_revenue)::numeric / prev_revenue * 100, 2) AS mom_growth_pct -- NULL prev_revenue automatically propagates NULL FROM lagged ORDER BY month; /* Logical SQL evaluation order: 1. CTE lagged — FROM monthly_revenue → read 6 rows 2. LAG(revenue, 1) OVER (...) → attach the previous month's revenue 3. FROM lagged → reference the CTE 4. (revenue - prev) / prev * 100 → calculate the month-over-month growth rate 5. ROUND(..., 2) → round to 2 decimal places 6. ORDER BY month → sort chronologically 7. SELECT → output 6 rows */
LEGEND
① FROM monthly_revenue — six months of monthly revenue data
FROM monthly_revenueRead 6 rows of monthly revenue data from January through June 2024. Put each month's previous revenue on the same row with LAG, then calculate month-over-month growth in one query without a self JOIN.| month | ▸ revenue |
|---|---|
| 2024-01 | 1,200,000 |
| 2024-02 | 1,350,000 |
| 2024-03 | 1,180,000 |
| 2024-04 | 1,520,000 |
| 2024-05 | 1,680,000 |
| 2024-06 | 1,450,000 |
COALESCE(mom_growth_pct, 0); to display it as “−”, post-process it with COALESCE such as COALESCE(CAST(mom_growth_pct AS TEXT), '−').LAG(revenue, 1, 0), returns the specified default value (0 in this example) instead of NULL when there is no preceding row. However, this can cause division by zero, so take care when calculating growth rates. Choose between NULL propagation and a default value according to the purpose.LAG(revenue, 1) OVER () leaves the row-reference order undefined, so the month-over-month rate may change from one execution to the next. Always specify ORDER BY for LAG/LEAD to establish the time-series order./ prev_revenue raises a division-by-zero error. The standard production pattern is to convert zero to NULL with NULLIF(prev_revenue, 0) before dividing: (revenue - prev_revenue)::numeric / NULLIF(prev_revenue, 0) * 100.PARTITION BY product_category also enables time-series comparisons by category in the same query, which can serve directly as a data-supply query for a BI tool.