Competitive Analysis — Learn Multiple CTEs, Frame Clauses, and FIRST_VALUE in Practice
AdvancedCompetitive AnalysisMultiple CTEsFrame ClausesFIRST_VALUEPostgreSQL Compatible5 questions
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

Only Churn rate (%) is a reverse-direction metric: lower values are better.

Expected Output

Expected output (overall_rank ascending → company_name ascending):

company_namemetrics_achievedtotal_metricsachievement_rateoverall_rank
AlphaTech3475.01
BetaSoft2450.02
GammaSys2450.02
DeltaNet040.03

AlphaTech misses NPS but achieves the other three metrics and ranks first. BetaSoft and GammaSys tie at a 50.0% achievement rate and both rank second. Because this uses DENSE_RANK, DeltaNet is third rather than fourth.

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

Expected output (company_name ascending → quarter ascending):

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

Q1 has no preceding row, so its average uses only the current row. For AlphaTech Q4, (100+108+115)/3=107.7. The cumulative value is simply the annual total.

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

Expected output (category ascending → annual_revenue descending):

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

AlphaTech and DeltaNet tie at 1200 in Security. For a tie, CUME_DIST uses the four rows at or below that value as the numerator, producing 80.0%.

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, sorted by category ascending and then composite_score descending.

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

Expected output (category ascending → composite_score descending):

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

The leader differs by category; only BetaSoft leads in Security. A gap_to_leader of 0 identifies each category's leader at a glance.

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

Expected output (company_name ascending → period ascending; first periods excluded, 8 rows):

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

AlphaTech and GammaSys expanded for two consecutive periods (expanding_periods=2). BetaSoft and DeltaNet declined in both periods (expanding_periods=0).