SQL Benchmarking — Applied Multiple CTEs, FIRST_VALUE

ADVCompetitive AnalysisMultiple CTEsFrame ClausesFIRST_VALUEPostgreSQL Compatible5 questions
1 / 5 · In progress 0 / 5 completed
QUESTION 1

Competitor Scorecard — Evaluate KPI Achievement with Multiple CTEs × Conditional Aggregation

WITH CTESUM(CASE WHEN)ScorecardDENSE_RANK
Background

Chaining multiple CTEs (Common Table Expressions) lets you split the transformation from raw data → evaluation → aggregation → ranking into clear stages. In particular, conditional aggregation (SUM(CASE WHEN ...)) is a common pattern for counting “achieved / not achieved” rows by summing a flag column.

WITH eval AS (
  SELECT ...,
    CASE WHEN value >= target THEN 1 ELSE 0 END AS achieved
  FROM raw_table
),
scored AS (
  SELECT company_name,
    SUM(achieved) AS metrics_achieved,
    ROUND(SUM(achieved) * 100.0 / COUNT(*), 1) AS achievement_rate
  FROM eval GROUP BY company_name
)
The key to a CTE chain: the result of one CTE can be referenced in the next CTE's FROM. Splitting the logic into stages greatly improves readability and debuggability. A two-stage design—aggregate with GROUP BY inside a CTE, then apply a window function outside—is a standard practice.
Problem

The competitor_metrics table stores KPI actuals and targets for each company. Calculate each company's KPI achievement count, achievement rate, and overall rank, remembering that a lower churn rate is better. Return company_name, metrics_achieved, total_metrics, achievement_rate, overall_rank, sorted by overall_rank ascending and then company_name ascending.

Tables
▸ competitor_metrics (16 rows)
company_namemetric_namevaluetarget
AlphaTechRevenue growth (%)18.815.0
AlphaTechMarket share (%)36.535.0
AlphaTechNPS4250
AlphaTechChurn rate (%)3.24.0
BetaSoftRevenue growth (%)19.215.0
BetaSoftMarket share (%)28.030.0
BetaSoftNPS5550
BetaSoftChurn rate (%)4.84.0
GammaSysRevenue growth (%)22.215.0
GammaSysMarket share (%)18.020.0
GammaSysNPS3850
GammaSysChurn rate (%)2.54.0
DeltaNetRevenue growth (%)14.015.0
DeltaNetMarket share (%)15.035.0
DeltaNetNPS3550
DeltaNetChurn rate (%)5.54.0
Expected Output
company_namemetrics_achievedtotal_metricsachievement_rateoverall_rank
AlphaTech3475.01
BetaSoft2450.02
GammaSys2450.02
DeltaNet040.03
Model Answer
WITH eval AS (
  SELECT
    company_name, metric_name, value, target,
    CASE
      WHEN metric_name = 'Churn rate (%)' AND value <= target THEN 1  -- Reverse direction: achieved when value <= target
      WHEN metric_name != 'Churn rate (%)' AND value >= target THEN 1  -- Normal direction: achieved when value >= target
      ELSE 0                                                        -- Not achieved
    END AS achieved
  FROM   competitor_metrics
),
scored AS (
  SELECT
    company_name,
    SUM(achieved)                               AS metrics_achieved,  -- Sum the achievement flags
    COUNT(*)                                     AS total_metrics,
    ROUND(SUM(achieved) * 100.0 / COUNT(*), 1) AS achievement_rate  -- Calculate achievement rate (%)
  FROM   eval
  GROUP BY company_name
)
SELECT
  company_name, metrics_achieved, total_metrics, achievement_rate,
  DENSE_RANK() OVER (ORDER BY achievement_rate DESC) AS overall_rank  -- Ties share a rank; numbering remains consecutive
FROM   scored
ORDER BY overall_rank, company_name;

/*
  Execution order (logical SQL evaluation order):
  1. Define CTE eval       → Define the CTE and evaluate achievement flags
  2. Define CTE scored     → Group rows and evaluate aggregate functions
  3. Outer query            → Evaluate the DENSE_RANK window function (row count is preserved)
  4. ORDER BY              → Sort and return the result
*/
Explanation (table transitions & key points)
WITH eval AS ( SELECT company_name, metric_name, value, target, CASE WHEN metric_name='Churn rate (%)' AND value<=target THEN 1 WHEN metric_name!='Churn rate (%)' AND value>=target THEN 1 ELSE 0 END AS achieved FROM competitor_metrics ), scored AS ( SELECT company_name, SUM(achieved) AS metrics_achieved, COUNT(*) AS total_metrics, ROUND(SUM(achieved)*100.0/COUNT(*),1) AS achievement_rate FROM eval GROUP BY company_name ), SELECT company_name, metrics_achieved, total_metrics, achievement_rate, DENSE_RANK() OVER(ORDER BY achievement_rate DESC) AS overall_rank FROM scored ORDER BY overall_rank, company_name;
LEGEND
Rows read / loaded
① FROM competitor_metrics
FROM competitor_metricsRead the 16 rows from competitor_metrics. The achieved flag is calculated from the metric_name, value, and target columns.
1 / 4
company_namemetric_namevaluetarget
AlphaTechRevenue growth (%)18.815
AlphaTechMarket share (%)36.535
AlphaTechNPS4250
AlphaTechChurn rate (%)3.24
BetaSoftRevenue growth (%)19.215
BetaSoftMarket share (%)2830
BetaSoftNPS5550
BetaSoftChurn rate (%)4.84
GammaSysRevenue growth (%)22.215
GammaSysMarket share (%)1820
GammaSysNPS3850
GammaSysChurn rate (%)2.54
DeltaNetRevenue growth (%)1415
DeltaNetMarket share (%)1535
DeltaNetNPS3550
DeltaNetChurn rate (%)5.54
16 rows read
LEARNING POINTS
Splitting work into CTE-chain stages: the three-stage structure eval → scored → outer query separates flag calculation, aggregation, and ranking. Each CTE can be debugged independently with a query such as SELECT * FROM eval, which substantially reduces maintenance cost compared with putting everything into one long SELECT.
The SUM(CASE WHEN) conditional-aggregation pattern: SUM(CASE WHEN condition THEN 1 ELSE 0 END) is a general-purpose pattern for totaling rows that satisfy a condition. COUNT(CASE WHEN condition THEN 1 END) produces the same result, but the SUM pattern is easier to extend with weighting (such as THEN 2) and is common in practice.
Choosing DENSE_RANK versus RANK: DENSE_RANK is appropriate for a competitor scorecard when ties should share a rank without skipping the next number. If BetaSoft and GammaSys tie for second, RANK produces “1,2,2,4” and makes DeltaNet fourth, whereas DENSE_RANK preserves consecutive numbering as “1,2,2,3”.
ANTI-PATTERNS
Forgetting reverse-direction metrics: if metrics such as churn rate, cost, or defect rate—where lower is better—are evaluated with the same value >= target condition as other metrics, good performance is marked as not achieved. A standard practice is to store the metric direction (higher is better / lower is better) in the data during design.
Trying to place the window function in the same SELECT after GROUP BY: SELECT SUM(achieved), DENSE_RANK() OVER (...) FROM eval GROUP BY company_name does work, because the window function is applied to the aggregated data after GROUP BY. However, separating the aggregation into a CTE makes the intent clearer and reduces the chance of bugs.
Field Note: KPI Scorecards and Balanced Scorecards
This pattern is core logic for a competitor-monitoring dashboard. Scoring achievement across multiple dimensions—revenue, customer satisfaction, operations, and finance—makes it possible to see “which competitor is strongest overall” in one table. Extending it to a weighted score with SUM(achieved * weight) / SUM(weight) uses the same syntax pattern.
QUESTION 2

Moving Average × Cumulative Revenue — Read Quarterly Trends with ROWS BETWEEN Frame Clauses

AVG OVERROWS BETWEENMoving AverageCumulative Aggregation
Background

Specifying a window-function frame clause (ROWS BETWEEN) gives precise control over how many preceding and following rows are included in an aggregation. A moving average and a cumulative total are representative applications.

Frame clauseMeaningTypical use
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWPrevious 2 rows through the current row (3 rows total)3-period moving average
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWFirst row through the current row (full cumulative range)Cumulative revenue / count
ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWINGCurrent row through the last rowRemaining total
AVG(revenue) OVER (
  PARTITION BY company_name
  ORDER BY     quarter
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- 3-quarter moving average
)
Difference from the default frame: when ORDER BY is specified, the default frame is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (cumulative). To target only the most recent N rows, as a moving average does, always specify ROWS BETWEEN.
Problem

For each company's quarterly revenue in the quarterly_revenue table, calculate a 3-quarter moving average (moving_avg_3q) and cumulative revenue (cumulative_rev). Return company_name, quarter, revenue, moving_avg_3q, cumulative_rev, sorted by company_name ascending and then quarter ascending.

Tables
▸ quarterly_revenue (12 rows)
company_namequarterrevenue
AlphaTech2024Q195
AlphaTech2024Q2100
AlphaTech2024Q3108
AlphaTech2024Q4115
BetaSoft2024Q168
BetaSoft2024Q272
BetaSoft2024Q375
BetaSoft2024Q478
GammaSys2024Q138
GammaSys2024Q242
GammaSys2024Q345
GammaSys2024Q450

Revenue unit: JPY 100 million

Expected Output
company_namequarterrevenuemoving_avg_3qcumulative_rev
AlphaTech2024Q19595.095
AlphaTech2024Q210097.5195
AlphaTech2024Q3108101.0303
AlphaTech2024Q4115107.7418
BetaSoft2024Q16868.068
BetaSoft2024Q27270.0140
BetaSoft2024Q37571.7215
BetaSoft2024Q47875.0293
GammaSys2024Q13838.038
GammaSys2024Q24240.080
GammaSys2024Q34541.7125
GammaSys2024Q45045.7175
Model Answer
SELECT
  company_name, quarter, revenue,
  ROUND(
    AVG(revenue) OVER (
      PARTITION BY company_name               -- Independent window for each company
      ORDER BY     quarter
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- Average the previous 2 rows plus the current row
    ), 1
  ) AS moving_avg_3q,
  SUM(revenue) OVER (
    PARTITION BY company_name               -- Independent window for each company
    ORDER BY     quarter
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- Cumulative total from the first row through the current row
  ) AS cumulative_rev
FROM   quarterly_revenue
ORDER BY company_name, quarter;

/*
  Execution order (logical SQL evaluation order):
  1. FROM quarterly_revenue         → Read the rows
  2. AVG/SUM OVER (...)             → Evaluate the window functions (row count is preserved)
  3. SELECT                        → Evaluate the columns
  4. ORDER BY company_name, quarter → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT company_name, quarter, revenue, ROUND( AVG(revenue) OVER ( PARTITION BY company_name ORDER BY quarter ROWS BETWEEN 2 PRECEDING AND CURRENT ROW ), 1) AS moving_avg_3q, SUM(revenue) OVER ( PARTITION BY company_name ORDER BY quarter ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_rev FROM quarterly_revenue ORDER BY company_name, quarter;
LEGEND
Rows read / loaded
① FROM quarterly_revenue
FROM quarterly_revenueRead the 12 rows from quarterly_revenue. PARTITION BY company_name creates an independent window for each company.
1 / 3
company_namequarterrevenue
AlphaTech2024Q195
AlphaTech2024Q2100
AlphaTech2024Q3108
AlphaTech2024Q4115
BetaSoft2024Q168
BetaSoft2024Q272
BetaSoft2024Q375
BetaSoft2024Q478
GammaSys2024Q138
GammaSys2024Q242
GammaSys2024Q345
GammaSys2024Q450
12 rows read
LEARNING POINTS
ROWS BETWEEN frame specification: ROWS BETWEEN 2 PRECEDING AND CURRENT ROW means “from the two rows immediately before the current row through the current row,” for at most three rows. Boundary keywords include UNBOUNDED PRECEDING (first row), N PRECEDING, CURRENT ROW, N FOLLOWING, and UNBOUNDED FOLLOWING (last row).
Difference from the default frame with ORDER BY: when ORDER BY is specified, omitting the frame defaults to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (cumulative). If only the most recent N rows are needed, as in a moving average, omitting ROWS BETWEEN would calculate a cumulative average instead.
Parallel calculation of two window functions: moving_avg_3q and cumulative_rev appear in the same SELECT clause and are logically evaluated in parallel over the same FROM result. The core flexibility of window functions is that each expression can have its own frame clause.
ANTI-PATTERNS
Calculating a moving average without PARTITION BY: omitting PARTITION BY makes BetaSoft “previous two rows” AlphaTech's third and fourth quarters, producing a meaningless average across companies. Treat PARTITION BY as required for time-series window functions.
Confusing ROWS with RANGE: RANGE BETWEEN 2 PRECEDING AND CURRENT ROW is a value-based frame that targets rows whose values are at least the current value minus 2. It may not behave as intended with the string quarter column. Use ROWS BETWEEN for a row-count frame and RANGE BETWEEN INTERVAL '...' PRECEDING for a date range.
Field Note: Using Moving Averages for Seasonal Adjustment
In competitive-analysis reports, a moving average often reflects the “real trend” better than a simple quarter-to-quarter comparison. Because SaaS companies frequently concentrate large contracts in the fourth quarter, a 3- or 4-period moving average is commonly used to remove noise. Cumulative revenue (cumulative_rev) also connects directly to annual-plan tracking, and cumulative window functions help visualize what percentage of the annual target has been achieved by Q3.
QUESTION 3

Competitor Percentile Analysis — Quantify Market Position with PERCENT_RANK / CUME_DIST

PERCENT_RANKCUME_DISTPercentilesMarket Position
Background

PERCENT_RANK and CUME_DIST both express a row's relative position within a group as a percentage, but their formulas differ.

FunctionFormulaMinimumMaximumInterpretation
PERCENT_RANK()(rank − 1) / (n − 1)0.0 (minimum row)1.0 (maximum row)Share of values smaller than this value
CUME_DIST()rank / n1/n (minimum row)1.0 (maximum row)Share of values less than or equal to this value
PERCENT_RANK() OVER (
  PARTITION BY category
  ORDER BY     annual_revenue   -- Ascending: the maximum is 100%
) * 100 AS pct_rank
When n=5: PERCENT_RANK for the lowest row (rank=1) is (1-1)/(5-1) = 0%, while CUME_DIST is 1/5 = 20%. CUME_DIST never becomes 0%, even for the lowest row.
Problem

For each company's category revenue in the category_performance table, calculate the within-category percentile rank (pct_rank) and cumulative distribution (cume_dist_pct) on a 0–100 scale, rounded to one decimal place. Return category, company_name, annual_revenue, pct_rank, cume_dist_pct, sorted by category ascending and then annual_revenue descending.

Tables
▸ category_performance (10 rows — 5 companies × 2 categories)
company_namecategoryannual_revenue
AlphaTechCloud4200
BetaSoftCloud2800
GammaSysCloud1500
DeltaNetCloud800
EpsilonIOCloud500
BetaSoftSecurity2200
AlphaTechSecurity1200
DeltaNetSecurity1200
GammaSysSecurity900
EpsilonIOSecurity600

Annual revenue unit: JPY 100 million

Expected Output
categorycompany_nameannual_revenuepct_rankcume_dist_pct
CloudAlphaTech4200100.0100.0
CloudBetaSoft280075.080.0
CloudGammaSys150050.060.0
CloudDeltaNet80025.040.0
CloudEpsilonIO5000.020.0
SecurityBetaSoft2200100.0100.0
SecurityAlphaTech120050.080.0
SecurityDeltaNet120050.080.0
SecurityGammaSys90025.040.0
SecurityEpsilonIO6000.020.0
Model Answer
SELECT
  category, company_name, annual_revenue,
  ROUND(
    (PERCENT_RANK() OVER (
      PARTITION BY category             -- Independent window for each category
      ORDER BY     annual_revenue        -- Ascending: minimum=0%, maximum=100%
    ) * 100)::numeric, 1
  ) AS pct_rank,
  ROUND(
    (CUME_DIST() OVER (
      PARTITION BY category             -- Independent window for each category
      ORDER BY     annual_revenue        -- The share of values at or below the current value
    ) * 100)::numeric, 1
  ) AS cume_dist_pct
FROM   category_performance
ORDER BY category, annual_revenue DESC;

/*
  Execution order (logical SQL evaluation order):
  1. FROM category_performance       → Read the rows
  2. PERCENT_RANK/CUME_DIST OVER     → Evaluate the window functions (row count is preserved)
  3. ROUND(...)                      → Format the values
  4. SELECT                          → Evaluate the columns
  5. ORDER BY category, annual_revenue DESC → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT category, company_name, annual_revenue, ROUND( (PERCENT_RANK() OVER ( PARTITION BY category ORDER BY annual_revenue ) * 100)::numeric, 1) AS pct_rank, ROUND( (CUME_DIST() OVER ( PARTITION BY category ORDER BY annual_revenue ) * 100)::numeric, 1) AS cume_dist_pct FROM category_performance ORDER BY category, annual_revenue DESC;
LEGEND
Rows read / loaded
① FROM category_performance
FROM category_performanceRead the 10 rows from category_performance, with five companies in each category. The window functions are evaluated with ORDER BY annual_revenue ascending, so the data is shown in ascending order at this stage.
1 / 4
categorycompany_nameannual_revenue
CloudEpsilonIO500
CloudDeltaNet800
CloudGammaSys1500
CloudBetaSoft2800
CloudAlphaTech4200
SecurityEpsilonIO600
SecurityGammaSys900
SecurityAlphaTech1200
SecurityDeltaNet1200
SecurityBetaSoft2200
10 rows read (ascending display illustrates the rank calculation)
LEARNING POINTS
PERCENT_RANK formula and the minimum: (rank-1)/(n-1). With n=5, rank=1 (minimum) gives 0.0, rank=3 gives 0.5, and rank=5 (maximum) gives 1.0. The minimum percentile is always 0.0 (“the share of values smaller than this value” is 0%), so do not misread it as a “lowest score” in an absolute sense.
CUME_DIST formula and interpretation: rank/n. With n=5, even the minimum is 1/5=20%. EpsilonIO's cume_dist=20% means that 20% of companies in the Cloud market have revenue less than or equal to EpsilonIO's. Unlike PERCENT_RANK, it never becomes 0%.
Different behavior when values tie: when two companies have the same revenue, PERCENT_RANK assigns them the same rank, while CUME_DIST uses “the number of rows at or below that value / n”. For AlphaTech and DeltaNet in Security (both 1200), PERCENT_RANK is (3-1)/4 = 50.0% for both, while CUME_DIST is 4/5 = 80.0% because four rows are at or below them, including the tied rows.
ANTI-PATTERNS
Misunderstanding “PERCENT_RANK=0 means the worst”: pct_rank=0 means only that the value is the minimum in its group; it is not an absolute evaluation. EpsilonIO's 600 in Security has pct_rank=0%, yet it is lower in absolute revenue than Cloud's DeltaNet at 800 and pct_rank=25%. PERCENT_RANK is always a relative comparison within a category.
Confusing it with NTILE: NTILE(4) returns an integer bucket number from 1 to 4, whereas PERCENT_RANK returns a continuous value from 0 to 1. Use NTILE for distribution segments and PERCENT_RANK for a quantitative expression of “what percentile is this?” Choose according to the purpose.
Field Note: Applying Percentile Analysis to Competitive Research
PERCENT_RANK and CUME_DIST are common in industry research reports and benchmark analyses. They directly answer questions such as “what percentile is our NPS within the industry?” and “what percentage of the market is priced below a competitor's product?” Wrap the window-function result in a CTE or subquery, then use a condition such as WHERE cume_dist_pct >= 0.8 in the outer query to filter to the top 20% of companies, enabling flexible percentile-threshold filtering that is difficult with NTILE.
QUESTION 4

Gap Analysis to the Category Leader — Visualize the Leader Gap with FIRST_VALUE × CTE

FIRST_VALUEWITH CTEGap AnalysisLeader Comparison
Background

FIRST_VALUE() attaches the value from the “first row” of the window defined in the OVER clause to every row. Combined with ORDER BY score DESC, it retrieves the maximum value—the leader's value—in each partition.

FunctionWhat it returnsUse
FIRST_VALUE(col)Value from the first row of the windowCategory leader's value or name
LAST_VALUE(col)Value from the last row of the window*Category's lowest value
NTH_VALUE(col,n)Value from the n-th row of the windowSecond- or third-place value
FIRST_VALUE(company_name) OVER (
  PARTITION BY category
  ORDER BY     composite_score DESC  -- Descending: the first row has the highest score
) AS leader_name
The LAST_VALUE trap: the default frame for LAST_VALUE is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW (cumulative through the current row), so it always returns “your own value”. To get the final row's value, you need ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
Problem

From the product_scores table, calculate the leader company name (leader_name), leader score (leader_score), and score gap to the leader (gap_to_leader) for each category. Use a CTE. Return category, company_name, composite_score, leader_name, leader_score, gap_to_leader, with categories in the specified Cloud, Security, Data Analytics order and composite_score descending within each category.

Tables
▸ product_scores (12 rows — 4 companies × 3 categories)
company_namecategorycomposite_score
AlphaTechCloud88.5
BetaSoftCloud79.2
GammaSysCloud71.0
DeltaNetCloud65.3
BetaSoftSecurity91.3
AlphaTechSecurity74.1
GammaSysSecurity68.7
DeltaNetSecurity55.2
AlphaTechData Analytics83.4
GammaSysData Analytics80.1
BetaSoftData Analytics76.8
DeltaNetData Analytics62.0
Expected Output
categorycompany_namecomposite_scoreleader_nameleader_scoregap_to_leader
CloudAlphaTech88.5AlphaTech88.50.0
CloudBetaSoft79.2AlphaTech88.5-9.3
CloudGammaSys71.0AlphaTech88.5-17.5
CloudDeltaNet65.3AlphaTech88.5-23.2
SecurityBetaSoft91.3BetaSoft91.30.0
SecurityAlphaTech74.1BetaSoft91.3-17.2
SecurityGammaSys68.7BetaSoft91.3-22.6
SecurityDeltaNet55.2BetaSoft91.3-36.1
Data AnalyticsAlphaTech83.4AlphaTech83.40.0
Data AnalyticsGammaSys80.1AlphaTech83.4-3.3
Data AnalyticsBetaSoft76.8AlphaTech83.4-6.6
Data AnalyticsDeltaNet62.0AlphaTech83.4-21.4
Model Answer
WITH ranked AS (
  SELECT
    category, company_name, composite_score,
    FIRST_VALUE(company_name) OVER (
      PARTITION BY category                -- Independent window for each category
      ORDER BY     composite_score DESC   -- Descending: first row has the highest score
    ) AS leader_name,
    FIRST_VALUE(composite_score) OVER (
      PARTITION BY category                -- Get the leader score in the same window
      ORDER BY     composite_score DESC
    ) AS leader_score
  FROM   product_scores
)
SELECT
  category, company_name, composite_score,
  leader_name, leader_score,
  ROUND(composite_score - leader_score, 1) AS gap_to_leader  -- Gap from the leader (negative means behind)
FROM   ranked
ORDER BY CASE category
  WHEN 'Cloud' THEN 1
  WHEN 'Security' THEN 2
  WHEN 'Data Analytics' THEN 3
  END, composite_score DESC, company_name;

/*
  Execution order (logical SQL evaluation order):
  1. Define CTE ranked        → Evaluate the FIRST_VALUE window functions (row count is preserved)
  2. Outer query               → Evaluate the gap and format it with ROUND
  3. ORDER BY CASE category WHEN 'Cloud' THEN 1 WHEN 'Security' THEN 2 WHEN 'Data Analytics' THEN 3 END, composite_score DESC, company_name → Sort and return the result
*/
Explanation (table transitions & key points)
WITH ranked AS ( SELECT category, company_name, composite_score, FIRST_VALUE(company_name) OVER ( PARTITION BY category ORDER BY composite_score DESC ) AS leader_name, FIRST_VALUE(composite_score) OVER ( PARTITION BY category ORDER BY composite_score DESC ) AS leader_score FROM product_scores ) SELECT category, company_name, composite_score, leader_name, leader_score, ROUND(composite_score - leader_score, 1) AS gap_to_leader FROM ranked ORDER BY CASE category WHEN 'Cloud' THEN 1 WHEN 'Security' THEN 2 WHEN 'Data Analytics' THEN 3 END, composite_score DESC, company_name;
LEGEND
Rows read / loaded
① FROM product_scores
FROM product_scoresRead the 12 rows from product_scores. There are four companies in each of Cloud, Security, and Data Analytics. FIRST_VALUE will identify the leader in each category in the next step.
1 / 3
categorycompany_namecomposite_score
CloudAlphaTech88.5
CloudBetaSoft79.2
CloudGammaSys71
CloudDeltaNet65.3
SecurityBetaSoft91.3
SecurityAlphaTech74.1
SecurityGammaSys68.7
SecurityDeltaNet55.2
Data AnalyticsAlphaTech83.4
Data AnalyticsGammaSys80.1
Data AnalyticsBetaSoft76.8
Data AnalyticsDeltaNet62
12 rows read
LEARNING POINTS
Get the leader's name and score together with FIRST_VALUE: MAX(composite_score) OVER (...) can retrieve the top score, but it cannot retrieve the company name that owns that score. Combining FIRST_VALUE(company_name) and FIRST_VALUE(composite_score) attaches the leader's “name and value” to every row, which is the key to gap analysis.
Remove duplicate window expressions with a CTE: writing FIRST_VALUE(...) OVER (...) twice in the outer query is verbose. Extracting it into the ranked CTE leaves the outer query with the concise expression composite_score - leader_score. In practice, extract repeated window expressions into a CTE or VIEW.
gap_to_leader=0.0 marks the leader: the leader's own score satisfies composite_score - leader_score = 0.0. This can be used to extract only the leaders with WHERE gap_to_leader = 0 by adding the condition to the outer query.
ANTI-PATTERNS
LAST_VALUE's default-frame problem: if you write LAST_VALUE(composite_score) OVER (PARTITION BY category ORDER BY composite_score DESC) with the default frame, the frame is cumulative through the current row, so it always returns “your own score”. To get the lowest score in the category, explicitly specify ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING.
MAX cannot retrieve the company name: MAX(composite_score) OVER (PARTITION BY category) retrieves the maximum score, but not “the company that owns that score”. FIRST_VALUE is required to identify who leads the competition. If only the score is needed and the company name is not, MAX is simpler.
Field Note: Using Gap Analysis in Product Roadmaps
By calculating gap_to_leader regularly (monthly or quarterly) and tracking its trend, you can objectively see whether the gap with a competitor is shrinking or widening. DeltaNet's -36.1 gap in Security signals that substantial improvement is needed to enter this market. Category-level gap analysis can directly support product-development prioritization and the evaluation of M&A candidates.
QUESTION 5

Share-Change Trend Analysis — Classify Winners and Losers with an LAG × CASE × SUM OVER CTE Chain

LAGSUM OVERCTE ChainTrend Classification
Background

A serial CTE chain organizes a complex data transformation into steps such as “calculate period-over-period change → classify → aggregate”. This question demonstrates how to connect three constructs: LAG, CASE WHEN, and SUM OVER.

WITH step1 AS (
  SELECT ..., LAG(val) OVER (PARTITION BY grp ORDER BY t) AS prev_val
  FROM src
),
step2 AS (
  SELECT ..., val - prev_val AS delta,
    CASE WHEN val > prev_val THEN '▲ Growth' ELSE '▼ Decline' END AS trend
  FROM step1
  WHERE prev_val IS NOT NULL  -- Exclude the NULL at the first period
)
SELECT ...,
  SUM(CASE WHEN trend = '▲ Growth' THEN 1 ELSE 0 END) OVER
    (PARTITION BY company_name ORDER BY period) AS expanding_periods
FROM step2;
The benefit of a CTE chain: each CTE can be debugged independently. You can first check only step1 with SELECT * FROM step1, layer step2 on top, and aggregate at the end—a gradual development process.
Problem

The market_share_trend table stores semiannual market share for four companies. Chain two CTEs to calculate ① the period-over-period share change (share_delta), ② the growth/decline label (trend), and ③ the cumulative number of expanding periods (expanding_periods). Exclude the first period (the row where prev_share is NULL). Return company_name, period, market_share_pct, prev_share, share_delta, trend, expanding_periods, sorted by company_name ascending and then period ascending.

Tables
▸ market_share_trend (12 rows — 4 companies × 3 periods)
company_nameperiodmarket_share_pct
AlphaTech2023H138.5
AlphaTech2023H240.2
AlphaTech2024H142.0
BetaSoft2023H130.1
BetaSoft2023H228.5
BetaSoft2024H126.8
DeltaNet2023H113.0
DeltaNet2023H212.3
DeltaNet2024H111.7
GammaSys2023H118.4
GammaSys2023H219.0
GammaSys2024H119.5
Expected Output
company_nameperiodmarket_share_pctprev_shareshare_deltatrendexpanding_periods
AlphaTech2023H240.238.51.7▲ Growth1
AlphaTech2024H142.040.21.8▲ Growth2
BetaSoft2023H228.530.1-1.6▼ Decline0
BetaSoft2024H126.828.5-1.7▼ Decline0
DeltaNet2023H212.313.0-0.7▼ Decline0
DeltaNet2024H111.712.3-0.6▼ Decline0
GammaSys2023H219.018.40.6▲ Growth1
GammaSys2024H119.519.00.5▲ Growth2
Model Answer
WITH share_with_prev AS (
  SELECT
    company_name, period, market_share_pct,
    LAG(market_share_pct) OVER (
      PARTITION BY company_name    -- Independent window for each company
      ORDER BY     period          -- Read the previous period's share in ascending period order
    ) AS prev_share               -- The first period (2023H1) has no previous row, so it is NULL
  FROM   market_share_trend
),
deltas AS (
  SELECT
    company_name, period, market_share_pct, prev_share,
    ROUND(market_share_pct - prev_share, 1) AS share_delta,  -- Period-over-period change
    CASE
      WHEN market_share_pct > prev_share THEN '▲ Growth'
      WHEN market_share_pct < prev_share THEN '▼ Decline'
      ELSE                                    '— Flat'
    END AS trend
  FROM   share_with_prev
  WHERE  prev_share IS NOT NULL           -- Exclude the first-period NULL rows
)
SELECT
  company_name, period,
  market_share_pct, prev_share, share_delta, trend,
  SUM(CASE WHEN trend = '▲ Growth' THEN 1 ELSE 0 END) OVER (
    PARTITION BY company_name          -- Accumulate separately for each company
    ORDER BY     period               -- Accumulate the growth flags in period order
  ) AS expanding_periods
FROM   deltas
ORDER BY company_name, period;

/*
  Execution order (logical SQL evaluation order):
  1. Define CTE share_with_prev → Evaluate LAG as a window function (row count is preserved)
  2. Define CTE deltas          → Filter with WHERE and evaluate the change and label
  3. Outer query                → Evaluate the SUM OVER window function (row count is preserved)
  4. ORDER BY company_name, period → Sort and return the result
*/
Explanation (table transitions & key points)
WITH share_with_prev AS ( SELECT company_name, period, market_share_pct, LAG(market_share_pct) OVER ( PARTITION BY company_name ORDER BY period ) AS prev_share FROM market_share_trend ), deltas AS ( SELECT company_name, period, market_share_pct, prev_share, ROUND(market_share_pct - prev_share, 1) AS share_delta, CASE WHEN market_share_pct > prev_share THEN '▲ Growth' WHEN market_share_pct < prev_share THEN '▼ Decline' ELSE '— Flat' END AS trend FROM share_with_prev WHERE prev_share IS NOT NULL ), SELECT company_name, period, market_share_pct, prev_share, share_delta, trend, SUM(CASE WHEN trend='▲ Growth' THEN 1 ELSE 0 END) OVER ( PARTITION BY company_name ORDER BY period ) AS expanding_periods FROM deltas ORDER BY company_name, period;
LEGEND
Rows read / loaded
Excluded / hidden data
① CTE share_with_prev: FROM + LAG()
WITH share_with_prev AS ( ... LAG(...) OVER(...) AS prev_share )Read the 12 rows from market_share_trend and attach the previous-period share with LAG() in company-and-period order. Each company's first period (2023H1) has no previous row, so prev_share=NULL (four rows).
1 / 4
company_nameperiodmarket_share_pct▸ prev_share
AlphaTech2023H138.5NULL
AlphaTech2023H240.238.5
AlphaTech2024H14240.2
BetaSoft2023H130.1NULL
BetaSoft2023H228.530.1
BetaSoft2024H126.828.5
DeltaNet2023H113NULL
DeltaNet2023H212.313
DeltaNet2024H111.712.3
GammaSys2023H118.4NULL
GammaSys2023H21918.4
GammaSys2024H119.519
12 rows — prev_share is NULL for each company's first period (2023H1)
LEARNING POINTS
Splitting work into CTE-chain stages: share_with_prev attaches the previous-period value with LAG, deltas calculates differences, classifies rows, and removes NULLs, and the outer query performs the cumulative count. This three-stage structure organizes a complex transformation, and each CTE can be selected independently for verification, which is the major practical benefit of a CTE chain.
Cumulative flag counting with SUM(CASE WHEN) OVER: SUM(CASE WHEN trend='▲ Growth' THEN 1 ELSE 0 END) OVER (PARTITION BY company_name ORDER BY period) calculates the cumulative number of periods in which each company has expanded up to that period. A window SUM with ORDER BY uses a cumulative default frame, so the count builds as periods advance.
Remove boundary rows with WHERE IS NOT NULL: specifying WHERE prev_share IS NOT NULL in CTE deltas removes each company's first period, for which LAG returns NULL. Without this filter, share_delta would be NULL for the first-period row and trend could be incorrectly classified as '— Flat'.
ANTI-PATTERNS
Crossing companies with LAG without PARTITION BY: writing LAG(market_share_pct) OVER (ORDER BY period) makes BetaSoft 2023H1's “previous row” AlphaTech 2024H1, so the period-over-period change crosses company boundaries. Meaningless differences such as 26.8→38.5 result. PARTITION BY is always required for time-series LAG.
Failing to exclude NULL rows causes a wrong expanding_periods total: when the first-period trend is NULL, the CASE in SUM(CASE WHEN trend='▲ Growth' THEN 1 ELSE 0 END) evaluates to ELSE 0 and the row is included in the count. Excluding the first period with WHERE sets the correct starting point for expanding_periods.
Field Note: Market-Share Trend Analysis and Competitive Intelligence
The distinction between expanding_periods = 2 (two consecutive expansions) and expanding_periods = 0 (only declines) is a simple but powerful way to quantify competitor momentum. In practice, calculate “how many of six periods expanded” over three to four years of semiannual data, then classify competitors as “aggressive / stable / retreating” to prioritize which competitors require defensive or offensive action.