Statistical Analysis — Learn CORR, Linear Regression, RANK, NTILE, and LAG from the Basics
BasicStatistics AnalysisCORR / Linear RegressionRANK / NTILELAGPostgreSQL Compatible5 questions
QUESTION 6
Correlation Analysis — Use CORR to calculate the Pearson correlation coefficient and quantify linear relationships between variables
CORRCorrelation AnalysisPearson CoefficientLinear Relationship
Background

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)
Can be combined with GROUP BY as an aggregate function: Because CORR is a standard aggregate function, 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.
Problem

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.

Source table
▸ ad_spend (6 rows)
weekad_costrevenue
1100280
2200650
3300780
44001100
55001250
66001640
Expected Output

Expected output (1 row):

corr_coefn
0.99146

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.

Model Answer
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
  */
Explanation (table transitions & key points)
SELECT ROUND(CORR(revenue, ad_cost)::numeric, 4) AS corr_coef, COUNT(*) AS n FROM ad_spend;
LEGEND
Rows read / loaded
① 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.
1 / 4
week▸ ad_cost▸ revenue
1100280
2200650
3300780
44001100
55001250
66001640
ad_spend: 6 rows (all rows are included in the CORR calculation)
LEARNING POINTS
How to interpret the correlation coefficient: As a rule of thumb, |r| ≥ 0.9 is “very strong,” 0.7–0.9 is “strong,” 0.4–0.7 is “moderate,” and below 0.4 is “weak.” The 0.9914 in this question would appear almost linear in a scatter plot, and variation in ad_cost explains about 98% of variation in revenue (R² = 0.9914² ≈ 0.98).
CORR and NULL handling: CORR(Y, X) automatically excludes a row if either X or Y is NULL. Comparing COUNT(*) with REGR_COUNT(Y, X) reveals a reduction in valid samples caused by NULL values.
Calculate correlations by category with GROUP BY: Add 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.
ANTI-PATTERNS
Concluding “correlation means causation”: A high CORR does not guarantee a causal relationship. The classic example of temperature creating a correlation between ice cream sales and drowning deaths shows that correlation only quantifies a tendency. Causal inference requires additional methods such as intervention studies and propensity-score analysis.
Trusting CORR without checking a scatter plot: As with Anscombe's quartet, completely different distribution shapes can have the same CORR value. After calculating CORR, always inspect the shape in a scatter plot so that you do not miss outliers, nonlinear relationships, or stratification effects.
Practical Column: Using correlation in marketing analysis
Correlation analysis between advertising cost and revenue is a first step toward quickly forming a hypothesis about which channels are effective. Channels with CORR > 0.7 are candidates for increased budgets, while CORR ≈ 0 is a signal to reconsider allocation. In practice, also calculate a one-week-lag correlation with LAG(ad_cost,1) OVER (ORDER BY week) to detect delayed effects on the following week's revenue.
QUESTION 7
Linear Regression — Build a regression model with REGR_SLOPE / REGR_INTERCEPT / REGR_R2 and predict revenue
REGR_SLOPEREGR_INTERCEPTLinear RegressionR² / Prediction
Background

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
Reuse intermediate results with a CTE: Repeating REGR_SLOPE and REGR_INTERCEPT in the same SELECT duplicates the calculation. The standard production pattern is to establish the statistics first in a WITH clause (CTE), then calculate slope × X + intercept in the outer query.
Problem

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.

Source table (same as Q6)
▸ ad_spend (6 rows)
weekad_costrevenue
1100280
2200650
3300780
44001100
55001250
66001640
Expected Output

Expected output (1 row):

slopeinterceptr_squaredpredicted_700
2.548658.000.98291842

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

Model Answer
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
  */
Explanation (table transitions & key points)
WITH stats AS ( 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 FROM stats;
LEGEND
Rows read / loaded
① 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.
1 / 5
week▸ ad_cost (X)▸ revenue (Y)
1100280
2200650
3300780
44001100
55001250
66001640
ad_spend: 6 rows (x̄=350, ȳ=950)
LEARNING POINTS
Business interpretation of slope and intercept: slope=2.5486 means that increasing advertising cost by 100 yen increases revenue by about 255 yen. intercept=58.00 is a hypothetical baseline—“the theoretical revenue when advertising cost is zero”—and may have no practical meaning because it is an extrapolation.
How to read R² (the coefficient of determination): R²=0.9829 means that the model explains 98.29% of the variance in the response variable. The remaining 1.71% is error not captured by the explanatory variables, such as seasonality, competitors, and weather. If R² is too close to 1.0, suspect overfitting and validate the model outside the training data.
Check the effect of NULL with REGR_COUNT: 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.
ANTI-PATTERNS
Extrapolating outside the training-data range: ad_cost=700 in this question is an extrapolation beyond the maximum of 600. A linear regression model is reliable only within the training-data range. Treat extrapolated predictions as reference values and revalidate after collecting additional data.
Applying simple regression to a multivariate problem: REGR_SLOPE(Y,X) is designed for simple regression with one explanatory variable. When there are multiple explanatory variables such as ad_cost + season + competitor, simple regression is insufficient; multiple regression requires integration with a specialized tool such as Python scikit-learn.
Practical Column: Automating revenue prediction with SQL
Calculate REGR_SLOPE / REGR_INTERCEPT in a CTE, then CROSS JOIN the outer query with a future_ad_spend table to estimate revenue for multiple advertising-cost scenarios at once. Returning three scenarios—budget 500/700/900 yen—in one query automates the numbers supplied to a proposal. You can also build parallel models by quarter with GROUP BY quarter, enabling advanced prediction such as using separate “summer” and “winter” models for categories with seasonal patterns.
QUESTION 8
Ranking Analysis — Understand how RANK / DENSE_RANK / ROW_NUMBER handle ties differently
RANKDENSE_RANKROW_NUMBERRankingPARTITION BYTie Handling
Background

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
Which one should you use: For fair rankings (gaps allowed) → RANK. For consecutive ranks when selecting the top N → DENSE_RANK. For a unique number per row (deduplication and pagination) → ROW_NUMBER (adding a tie-breaker is recommended).
Problem

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.

Source table
▸ sales_rep (9 rows)
rep_idregionsales_amount
1East8500
2East6200
3East8500
4West9100
5West7800
6West5500
7North7200
8North7200
9North4300
Expected Output

Expected output (9 rows, ascending by region, rank_val, and rep_id):

rep_idregionsales_amountrank_valdense_rank_valrow_num
1East8500111
3East8500112
2East6200323
7North7200111
8North7200112
9North4300323
4West9100111
5West7800222
6West5500333

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.

Model Answer
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
  */
Explanation (table transitions & key points)
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 FROM sales_rep ORDER BY region, rank_val, rep_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
rep_id▸ region▸ sales_amount
1East8500
2East6200
3East8500
4West9100
5West7800
6West5500
7North7200
8North7200
9North4300
sales_rep: 9 rows (East×3 / West×3 / North×3)
LEARNING POINTS
Quick guide to choosing among the 3 functions: For an official sports-style ranking where a tied second place leaves a gap → RANK. To retrieve everyone in the top N ranks, including ties → DENSE_RANK (WHERE dense_rank_val <= N). For pagination, deduplication, and a fixed number of rows → ROW_NUMBER (eliminate nondeterminism by adding a tie-breaker).
ROW_NUMBER is nondeterministic for ties: When the ORDER BY in the OVER clause contains ties, the sequence assigned by ROW_NUMBER may change from one execution to the next. To guarantee uniqueness, add a tie-breaker such as ORDER BY sales_amount DESC, rep_id ASC.
PARTITION BY changes the “unit” of ranking: Omitting PARTITION BY treats all rows as one group and produces a company-wide ranking. When independent rankings by region or category are required, explicitly specify PARTITION BY so that it matches the business definition of the ranking unit.
ANTI-PATTERNS
Ignore ties and extract first place with ROW_NUMBER: Filtering with WHERE row_num = 1 returns only one row and loses tied representatives. To retrieve everyone tied for first place, use WHERE rank_val = 1.
Try to get a fixed top N rows with RANK: With many ties, 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.
Practical Column: Ranking displays in a sales dashboard
When displaying a “top 3 by region” in a monthly sales dashboard, DENSE_RANK shows everyone tied for third, creating a fairer screen. ROW_NUMBER always fixes the display at 3 rows, keeping the layout stable. Use 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”.
QUESTION 9
Quantile Segmentation — Segment users into spending quartiles with NTILE
NTILECTEQuantilesUser SegmentsQuartiles
Background

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)
Establish the bucket number first in a CTE: You cannot reuse the alias of an NTILE column in another expression in the same SELECT. The standard production pattern is to establish quartile in a CTE (or subquery), then apply CASE WHEN in the outer query.
Problem

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.

Source table
▸ user_spend (8 rows)
user_idtotal_spent
1250
21800
3420
43200
5890
65600
7150
82100
Expected Output

Expected output (8 rows, ordered by quartile and total_spent DESC):

user_idtotal_spentquartilesegment
656001VIP
432001VIP
821002Heavy
218002Heavy
58903Middle
34203Middle
12504Light
71504Light

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.

Model Answer
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
  */
Explanation (table transitions & key points)
WITH segmented AS ( 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;
LEGEND
Rows read / loaded
① 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.
1 / 4
user_id▸ total_spent
1250
21800
3420
43200
5890
65600
7150
82100
user_spend: 8 rows (spending values are unordered)
LEARNING POINTS
When to use NTILE versus WIDTH_BUCKET: NTILE divides data into equal-sized groups by row count (quantiles), so the spending ranges in each bucket are not equal. In contrast, WIDTH_BUCKET from Q4 divides values into equal-width ranges. Use NTILE to select the “top N% of users,” and WIDTH_BUCKET to split by monetary ranges such as “the 0–2,000 yen tier.”
What happens when the row count is not divisible by n: For example, splitting 9 rows with NTILE(4) gives “9 ÷ 4 = 2 remainder 1,” so the extra row is added to the upper bucket (quartile=1), making that bucket contain 3 rows. Be careful: remainder rows are added from the top, not always to the lower buckets.
Combining NTILE with PERCENTILE_CONT: After splitting into segments with NTILE, aggregate statistics for each segment to gain deeper insight. Use 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.”
ANTI-PATTERNS
Mixing up ascending and descending order in ORDER BY: NTILE(4) OVER (ORDER BY total_spent ASC) makes quartile=1 the lowest-spending tier (Light). To make quartile=1 the top tier, always specify DESC. Make the intent explicit by adopting a habit of commenting “1=VIP (top 25%)” in the query.
Show NTILE bucket numbers directly to users: A user who sees quartile=4 may mistake it for “rank 4.” Always convert the number into a meaningful label with CASE WHEN before using it in a report. The CTE pattern cleanly establishes the bucket number before this conversion.
Practical Column: Extending the analysis to RFM
NTILE can serve as the foundation for RFM (Recency, Frequency, Monetary) analysis. A standard approach is to assign scores from 1 to 5 with NTILE(5) for each of the three metrics, then determine a user grade from the sum of R+F+M. For example, line up three window functions and aggregate them in a CTE, such as 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.
QUESTION 10
Period-over-Period Analysis — Calculate monthly growth rates (MoM) with LAG / LEAD in one query
LAGLEADPeriod-over-PeriodTime-Series AnalysisMoM Growth Rate
Background

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)
Establish LAG in a CTE: Writing LAG(...) twice in the month-over-month formula is verbose. It is clearer and easier to maintain to first establish prev_revenue = LAG(revenue, 1) OVER (...) in a CTE, then write (revenue - prev_revenue) / prev_revenue in the outer query.
Problem

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.

Source table
▸ monthly_revenue (6 rows)
monthrevenue
2024-011200000
2024-021350000
2024-031180000
2024-041520000
2024-051680000
2024-061450000
Expected Output

Expected output (6 rows, ascending by month):

monthrevenueprev_revenuemom_growth_pct
2024-011200000NULLNULL
2024-021350000120000012.50
2024-0311800001350000-12.59
2024-041520000118000028.81
2024-051680000152000010.53
2024-0614500001680000-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

Model Answer
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
  */
Explanation (table transitions & key points)
WITH lagged AS ( 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 FROM lagged ORDER BY month;
LEGEND
Rows read / loaded
① 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.
1 / 4
month▸ revenue
2024-011,200,000
2024-021,350,000
2024-031,180,000
2024-041,520,000
2024-051,680,000
2024-061,450,000
monthly_revenue: 6 rows (2024-01 to 2024-06)
LEARNING POINTS
Choosing between LAG and LEAD: LAG references the previous row within the window, while LEAD references the next row. Use LAG(revenue, 1) for month-over-month comparison, LEAD(revenue, 1) for the difference from the next month's forecast, and LAG(revenue, 12) for year-over-year comparison. Combining either with PARTITION BY also calculates month-over-month changes by category in one query.
NULL propagation and control with COALESCE: When prev_revenue is NULL in the first month, the expression (revenue - NULL) also propagates NULL automatically. This is normal behavior. To replace NULL with 0, use COALESCE(mom_growth_pct, 0); to display it as “−”, post-process it with COALESCE such as COALESCE(CAST(mom_growth_pct AS TEXT), '−').
The third argument of LAG (default value): Supplying a third argument, such as 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.
ANTI-PATTERNS
Omit ORDER BY from the OVER clause: Omitting ORDER BY with 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.
Trigger a division-by-zero error when prev_revenue is 0: If the previous month's revenue is 0, / 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.
Practical Column: Applying LAG to time-series dashboards
LAG can produce a time-series dashboard with month-over-month, year-over-year, and week-over-week growth rates in one table. A standard production pattern is to add LAG(revenue,1) (previous month), LAG(revenue,12) (same month last year), and LAG(revenue,4) (previous quarter) in a CTE, then calculate each rate in the outer query. Adding 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.