Competitive Analysis — Learn Anti-Joins, Moving Averages, and Cumulative Share from the Basics
BasicCompetitive AnalysisAnti-JoinMoving AverageHAVING / CROSS JOINCumulative SharePostgreSQL Compatible5 questions
QUESTION 6
Anti-Join — Find Markets Entered by Competitors but Not by Your Company with LEFT JOIN + IS NULL
LEFT JOINIS NULLMarket-Gap DiscoveryAnti-Join
Background

An anti-join is a pattern for retrieving records that “exist in A but not in B.” It uses a WHERE clause to filter on the property that rows without a LEFT JOIN match have NULL in the joined key column.

-- Basic anti-join pattern
SELECT a.*
FROM   table_a a
LEFT JOIN table_b b ON a.key = b.key   -- A missing match makes the b columns NULL
WHERE  b.key IS NULL;                  -- NULL leaves only rows absent from B
How this differs from NOT IN: NOT IN (subquery) has a trap: if the subquery result contains NULL, every row is excluded. LEFT JOIN + IS NULL is NULL-safe and generally gives stable performance, making it more dependable in production.
Problem

Among the markets entered by the three competitors, list every “market gap” your company has not entered (our_markets), including both company name and market ID. Return company_name, market_id, market_name, sorted by company_name ascending and then market_id ascending.

Tables
▸ markets
market_idmarket_name
1Domestic ERP
2Domestic CRM
3AI/ML
4IoT
5Data Analytics
▸ our_markets (markets entered by your company)
market_id
1
2
▸ competitor_markets (markets entered by competitors)
company_namemarket_id
BetaSoft1
BetaSoft2
BetaSoft3
GammaSys2
GammaSys4
GammaSys5
DeltaNet3
DeltaNet4
Expected Output

Expected output (company_name ascending → market_id ascending):

company_namemarket_idmarket_name
BetaSoft3AI/ML
DeltaNet3AI/ML
DeltaNet4IoT
GammaSys4IoT
GammaSys5Data Analytics

Rows matching markets your company has entered (market_id 1,2) are excluded. The three markets AI/ML, IoT, and Data Analytics emerge as competitive market gaps.

Model Answer
SELECT
  cm.company_name,
  m.market_id,
  m.market_name
FROM   competitor_markets cm
JOIN   markets m         USING (market_id)   -- Attach the market name
LEFT JOIN our_markets om  USING (market_id)  -- Left-join markets your company has entered
WHERE  om.market_id IS NULL                  -- A NULL row means your company has not entered the market
ORDER BY cm.company_name, m.market_id;

/*
  Execution order (logical SQL evaluation order):
  1. FROM competitor_markets cm        → Read the rows
  2. JOIN markets m                    → Join matching rows
  3. LEFT JOIN our_markets om          → Join while preserving every left-side row
  4. WHERE om.market_id IS NULL        → Filter the rows
  5. SELECT                            → Evaluate the columns
  6. ORDER BY company_name, market_id  → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT cm.company_name, m.market_id, m.market_name FROM competitor_markets cm JOIN markets m USING (market_id) LEFT JOIN our_markets om USING (market_id) WHERE om.market_id IS NULL ORDER BY cm.company_name, m.market_id;
LEGEND
Rows read / loaded
① FROM + JOIN markets
FROM competitor_markets cm JOIN markets m USING (market_id)Use the eight rows in competitor_markets as the starting point and INNER JOIN markets to attach market names. All eight rows remain, with market_name added to each row.
1 / 4
company_namemarket_idmarket_name
BetaSoft1Domestic ERP
BetaSoft2Domestic CRM
BetaSoft3AI/ML
GammaSys2Domestic CRM
GammaSys4IoT
GammaSys5Data Analytics
DeltaNet3AI/ML
DeltaNet4IoT
After JOIN: 8 rows
LEARNING POINTS
LEFT JOIN NULL proves absence: an INNER JOIN returns only matching rows, while a LEFT JOIN preserves every row from the left table and sets right-side columns to NULL for nonmatches. Interpreting “NULL = absent from the right table” is the core of an anti-join.
The decisive difference from NOT IN: WHERE market_id NOT IN (SELECT market_id FROM our_markets) can produce the same result, but if a NULL row enters our_markets, NOT IN excludes every row. LEFT JOIN + IS NULL is NULL-safe and robust against dirty production data.
Choosing between this pattern and NOT EXISTS: WHERE NOT EXISTS (SELECT 1 FROM our_markets om WHERE om.market_id = cm.market_id) is equivalent and can be easier for the query planner to optimize on large tables. LEFT JOIN + IS NULL is often favored for readability, while NOT EXISTS is favored for explicit intent.
ANTI-PATTERNS
The NULL trap in a NOT IN subquery: WHERE market_id NOT IN (SELECT market_id FROM our_markets) excludes every row if our_markets.market_id contains even one NULL row, because comparisons with NULL are UNKNOWN. Prefer LEFT JOIN + IS NULL over NOT IN for production data.
Confusing this with an INNER JOIN: changing to an INNER JOIN returns markets entered by both your company and a competitor. An anti-join requires the complete LEFT JOIN + WHERE right-table key IS NULL pattern. Omitting WHERE leaves an ordinary LEFT JOIN.
Field Note: Applying market-gap analysis in practice
Anti-joins are among the most common patterns in competitive analysis. Strategic priorities become clearer by examining set differences, such as deals your company lost among customers won by competitors, features competitors support that your product lacks, or advertising channels competitors use but your company does not. The EXCEPT operator performs the same set-difference operation, but it requires matching SELECT column counts and data types, so it has more constraints than the JOIN pattern.
QUESTION 7
Moving-Average Analysis — Smooth Competitor Quarterly Revenue Trends with ROWS BETWEEN
ROWS BETWEENAVG OVERMoving AverageWindow Frame
Background

Adding a window frame (ROWS BETWEEN) to OVER(ORDER BY ...) controls the range of rows to aggregate. It is essential for implementing moving averages.

AVG(revenue) OVER (
  PARTITION BY company_name
  ORDER BY     quarter
  ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- Average of the latest 3 rows
)
Frame specificationMeaning
ROWS BETWEEN 2 PRECEDING AND CURRENT ROWCurrent row + two preceding rows (3 rows total)
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROWFirst row through current row (cumulative)
ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWINGOne preceding row, current row, and one following row (3 rows total)
The default-frame trap: for an ordered window, the default frame is equivalent to RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW, which also includes rows tied with the current row. For a moving average, always specify ROWS BETWEEN N PRECEDING AND CURRENT ROW explicitly.
Problem

Using quarterly revenue data for three companies, calculate each company's three-quarter moving average (moving_avg_3q, rounded to one decimal place). Return company_name, quarter, revenue, moving_avg_3q, sorted by company_name ascending and then quarter ascending.

Tables
▸ quarterly_revenue
company_namequarterrevenue
AlphaTech2023Q1300
AlphaTech2023Q2360
AlphaTech2023Q3280
AlphaTech2023Q4400
BetaSoft2023Q1250
BetaSoft2023Q2220
BetaSoft2023Q3290
BetaSoft2023Q4310
GammaSys2023Q1120
GammaSys2023Q2150
GammaSys2023Q3170
GammaSys2023Q4160

revenue unit: JPY 100 million

Expected Output

Expected output (company_name ascending → quarter ascending):

company_namequarterrevenuemoving_avg_3q
AlphaTech2023Q1300300.0
AlphaTech2023Q2360330.0
AlphaTech2023Q3280313.3
AlphaTech2023Q4400346.7
BetaSoft2023Q1250250.0
BetaSoft2023Q2220235.0
BetaSoft2023Q3290253.3
BetaSoft2023Q4310273.3
GammaSys2023Q1120120.0
GammaSys2023Q2150135.0
GammaSys2023Q3170146.7
GammaSys2023Q4160160.0

At the first and second rows, the window contracts to the rows that exist (Q1 averages one row; Q2 averages two rows). AlphaTech Q4 has the highest moving average at 346.7.

Model Answer
SELECT
  company_name,
  quarter,
  revenue,
  ROUND(
    AVG(revenue) OVER (
      PARTITION BY company_name            -- An independent window for each company
      ORDER BY     quarter               -- Ascending quarter order defines the “current row”
      ROWS BETWEEN 2 PRECEDING AND CURRENT ROW  -- Cover the latest 3 quarters
    ), 1
  ) AS moving_avg_3q
FROM   quarterly_revenue
ORDER BY company_name, quarter;

/*
  Execution order (logical SQL evaluation order):
  1. FROM quarterly_revenue          → Read the rows
  2. AVG(revenue) OVER (...)         → Evaluate the window function while preserving the row count
  3. SELECT                          → Evaluate the columns (moving_avg_3q)
  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 FROM quarterly_revenue ORDER BY company_name, quarter;
LEGEND
Rows read / loaded
① FROM quarterly_revenue
FROM quarterly_revenueRead the 12 rows in quarterly_revenue (three companies × four quarters). PARTITION BY will next divide them into company windows, and ROWS BETWEEN will calculate the moving averages.
1 / 4
company_namequarterrevenue
AlphaTech2023Q1300
AlphaTech2023Q2360
AlphaTech2023Q3280
AlphaTech2023Q4400
BetaSoft2023Q1250
BetaSoft2023Q2220
BetaSoft2023Q3290
BetaSoft2023Q4310
GammaSys2023Q1120
GammaSys2023Q2150
GammaSys2023Q3170
GammaSys2023Q4160
12 rows read
LEARNING POINTS
How the frame contracts at the first row: because the “two preceding rows” for Q1, the first row, do not exist, its frame contains only one row. AVG(300) = 300.0. ROWS BETWEEN builds a frame only from rows that exist; it does not treat missing rows as 0. This behavior matters at the start of a season or in the initial data for a new competitor.
The difference between ROWS and RANGE: ROWS BETWEEN determines the frame by a physical row count. RANGE BETWEEN determines it by a value range and may include tied rows in the same frame. Always use ROWS for a row-based moving average. RANGE can unintentionally combine multiple rows for the same quarter.
How to choose N: a three-quarter moving average uses 2 PRECEDING: two preceding rows plus the current row equals three rows. For a 12-month moving average over monthly data, specify 11 PRECEDING. Remember that “an N-row moving average” means “(N-1) PRECEDING AND CURRENT ROW.”
ANTI-PATTERNS
Writing ROWS BETWEEN without ORDER BY: OVER (PARTITION BY company_name ROWS BETWEEN 2 PRECEDING AND CURRENT ROW) has no ORDER BY, so the “current row” order is indeterminate and the moving-average result is indeterminate. Always pair ROWS BETWEEN with ORDER BY.
Confusing a moving average with a cumulative aggregate that omits ROWS BETWEEN: specifying only ORDER BY and omitting ROWS BETWEEN uses the default frame UNBOUNDED PRECEDING AND CURRENT ROW, from the first row through the current row. A moving average and a cumulative aggregate are entirely different calculations. Always verify that the output is not an unintended cumulative value.
Field Note: Where moving averages help in competitor trend analysis
Quarterly revenue fluctuates in the short term because of seasonality, campaign effects, and differences in fiscal closing periods. Smoothing it with a moving average makes the underlying trend easier to see and helps determine whether a competitor is truly growing or the change is temporary noise. A common competitive-comparison dashboard design overlays each company's moving-average line and detects intersections where one company overtakes another. A ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING frame, symmetric around the current row, can also provide median-like smoothing and support before-and-after comparisons of event effects.
QUESTION 8
Compound HAVING Filters — Screen Competitors Against Multiple KPI Criteria at Once
GROUP BYHAVINGCompetitor ScreeningCompound Aggregate Conditions
Background

HAVING filters aggregate results after GROUP BY. WHERE is a row-level filter applied before aggregation, whereas HAVING is a group-level filter applied after aggregation.

SELECT   company_name, SUM(revenue)
FROM     data
WHERE    year = 2024         -- ① Row-level filter before aggregation
GROUP BY company_name
HAVING   SUM(revenue) > 1000  -- ② Group-level filter after aggregation
Combining multiple conditions with AND: a HAVING clause can combine multiple aggregate conditions with AND or OR, filtering on total revenue, average churn rate, and customer acquisition together.
Problem

From quarterly performance data for three competitors, select the competitors that satisfy all three conditions: “annual total revenue of at least JPY 100 billion,” “average churn rate of at most 5.0%,” and “at least 500,000 new customers acquired during the year.” Return company_name, total_revenue, avg_churn_pct, total_new_customers, sorted by total_revenue descending.

Tables
▸ competitor_metrics (units: revenue=JPY 100 million, new_customers=10,000 customers)
company_namequarterrevenuenew_customerschurn_rate_pct
AlphaTech2023Q1320153.2
AlphaTech2023Q2360182.8
AlphaTech2023Q3280124.1
AlphaTech2023Q4400203.5
BetaSoft2023Q1250106.2
BetaSoft2023Q222087.0
BetaSoft2023Q3290115.8
BetaSoft2023Q4310136.5
GammaSys2023Q112084.5
GammaSys2023Q2150103.8
GammaSys2023Q3170123.2
GammaSys2023Q416094.0
Expected Output

Expected output (total_revenue descending):

company_nametotal_revenueavg_churn_pcttotal_new_customers
AlphaTech13603.465

BetaSoft fails because avg_churn=6.4% (>5.0). GammaSys fails because total_revenue=600 (<1000) and total_new_customers=39 (<50). Only AlphaTech passes every condition.

Model Answer
SELECT
  company_name,
  SUM(revenue)                    AS total_revenue,
  ROUND(AVG(churn_rate_pct), 1)  AS avg_churn_pct,
  SUM(new_customers)              AS total_new_customers
FROM   competitor_metrics
GROUP BY company_name
HAVING   SUM(revenue)          >= 1000   -- Condition ①: annual revenue of at least JPY 100 billion
     AND AVG(churn_rate_pct)   <=    5.0  -- Condition ②: average churn rate of at most 5.0%
     AND SUM(new_customers)    >=   50   -- Condition ③: at least 500,000 new customers during the year
ORDER BY total_revenue DESC;

/*
  Execution order (logical SQL evaluation order):
  1. FROM competitor_metrics      → Read the rows
  2. GROUP BY company_name        → Form the groups
  3. SUM/AVG                      → Evaluate the aggregate functions
  4. HAVING (3 conditions)        → Filter the groups
  5. SELECT                       → Evaluate the columns
  6. ORDER BY total_revenue DESC  → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT company_name, SUM(revenue) AS total_revenue, ROUND(AVG(churn_rate_pct), 1) AS avg_churn_pct, SUM(new_customers) AS total_new_customers FROM competitor_metrics GROUP BY company_name HAVING SUM(revenue) >= 1000 AND AVG(churn_rate_pct) <= 5.0 AND SUM(new_customers) >= 50 ORDER BY total_revenue DESC;
LEGEND
Rows read / loaded
① FROM competitor_metrics (12 rows)
FROM competitor_metricsRead the 12 rows in competitor_metrics (three companies × four quarters). GROUP BY will aggregate them into three company groups, and HAVING will then apply the condition filters.
1 / 4
company_namequarterrevenuenew_customerschurn_rate_pct
AlphaTech2023Q1320153.2
AlphaTech2023Q2360182.8
AlphaTech2023Q3280124.1
AlphaTech2023Q4400203.5
BetaSoft2023Q1250106.2
BetaSoft2023Q222087
BetaSoft2023Q3290115.8
BetaSoft2023Q4310136.5
GammaSys2023Q112084.5
GammaSys2023Q2150103.8
GammaSys2023Q3170123.2
GammaSys2023Q416094
12 rows read (3 companies × 4 quarters)
LEARNING POINTS
Different evaluation points for WHERE and HAVING: WHERE is evaluated row by row before GROUP BY and cannot use aggregate functions such as SUM or AVG. HAVING is evaluated group by group after GROUP BY and can use aggregate functions. Use WHERE to include only quarterly rows of at least JPY 20 billion, and HAVING to return only companies whose annual total is at least JPY 100 billion.
Do not depend on AND evaluation order: the AND-connected HAVING conditions retain only groups for which all conditions are true, but SQL does not guarantee written-order evaluation or short-circuiting. The optimizer may reorder conditions. Do not make performance or error prevention depend on left-to-right evaluation; write every condition so it is safe on its own.
SELECT aliases cannot be used in HAVING: in standard SQL and PostgreSQL, HAVING cannot reference an alias from the same SELECT list, such as total_revenue. HAVING total_revenue >= 1000 raises an error. Write the aggregate expression directly in HAVING, or put the aggregation in a subquery or CTE and filter its alias with an outer WHERE.
ANTI-PATTERNS
Putting an aggregate function in WHERE: WHERE SUM(revenue) >= 1000 is a SQL syntax error. Aggregate functions cannot be used in WHERE. Always put post-aggregation conditions in HAVING.
Using HAVING as a substitute for every prefilter: if you want to aggregate only 2023 data, replacing WHERE with HAVING reads every period before excluding data and is inefficient. Use WHERE for row-level filtering and HAVING for post-aggregation filtering.
Field Note: Applying competitor screening in practice
Screening with compound HAVING conditions directly supports automated selection of investment targets, partners, and benchmarking candidates. Connecting multiple financial indicators with AND—for example, revenue growth above 20%, profit margin above 15%, and at least 1 million customers—is common in equity screeners and competitor-KPI monitoring dashboards. When thresholds must be adjustable, combine the query with parameters or placeholders and implement an interactive filter that lets users change thresholds dynamically.
QUESTION 9
CROSS JOIN + COALESCE — Fill Missing Cells with 0 in an All-Company × All-Category Comparison Matrix
CROSS JOINCOALESCEMatrix CompletionNULL→0 Conversion
Background

CROSS JOIN generates every combination of rows from its left and right tables: the Cartesian product. Combined with LEFT JOIN, it is useful when you need a complete matrix with no missing combinations, such as “all companies × all categories.”

SELECT c.company_name, cat.category_name,
       COALESCE(sd.revenue, 0) AS revenue   -- Replace NULL with 0
FROM   companies   c
CROSS JOIN categories cat              -- Generate every combination (3 companies × 3 categories = 9 rows)
LEFT JOIN  sales_data sd
         ON sd.company_id  = c.company_id
        AND sd.category_id = cat.category_id  -- No recorded result produces NULL
Using COALESCE: COALESCE(expr1, expr2, ...) evaluates from left to right and returns the first non-NULL value. COALESCE(sd.annual_revenue, 0) returns 0 when annual_revenue is NULL, meaning the company has not entered the category, converting it to a comparable number.
Problem

Using the revenue records for three companies, generate an all-company × all-category matrix and fill categories with no entry with 0. Return company_name, category_name, annual_revenue, using 0 for a missing value, and sort by company_name ascending and then category_id ascending.

Tables
▸ companies
company_idcompany_name
1AlphaTech
2BetaSoft
3GammaSys
▸ categories
category_idcategory_name
1Cloud
2Security
3AI/ML
▸ sales_data (only 6 recorded rows)
company_idcategory_idannual_revenue
114200
121800
212800
231500
32900
33650

annual_revenue unit: JPY 100 million / 3 missing combinations (AlphaTech-AI/ML, BetaSoft-Security, GammaSys-Cloud)

Expected Output

Expected output (company_name ascending → category_id ascending):

company_namecategory_nameannual_revenue
AlphaTechCloud4200
AlphaTechSecurity1800
AlphaTechAI/ML0
BetaSoftCloud2800
BetaSoftSecurity0
BetaSoftAI/ML1500
GammaSysCloud0
GammaSysSecurity900
GammaSysAI/ML650

The three rows whose annual_revenue is 0 are unentered categories filled by COALESCE. The six recorded rows expand to nine rows: all companies × all categories.

Model Answer
SELECT
  c.company_name,
  cat.category_name,
  COALESCE(sd.annual_revenue, 0) AS annual_revenue  -- Replace NULL with 0
FROM       companies   c
CROSS JOIN categories  cat                     -- Every one of 3 companies × 3 categories = 9 combinations
LEFT JOIN  sales_data  sd
           ON  sd.company_id  = c.company_id
           AND sd.category_id = cat.category_id  -- Match on both keys
ORDER BY c.company_name, cat.category_id;

/*
  Execution order (logical SQL evaluation order):
  1. FROM companies c                    → Read the rows
  2. CROSS JOIN categories cat           → Join with a Cartesian product
  3. LEFT JOIN sales_data sd             → Join while preserving every left-side row
  4. SELECT                              → Evaluate the columns and format annual_revenue
  5. ORDER BY company_name, category_id  → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT c.company_name, cat.category_name, COALESCE(sd.annual_revenue, 0) AS annual_revenue FROM companies c CROSS JOIN categories cat LEFT JOIN sales_data sd ON sd.company_id = c.company_id AND sd.category_id = cat.category_id ORDER BY c.company_name, cat.category_id;
LEGEND
Rows read / loaded
① CROSS JOIN companies × categories → All 9 combinations
FROM companies c CROSS JOIN categories catCROSS JOIN companies (three rows) and categories (three rows). This generates all 3×3=9 combinations. Every (company_id, category_id) pair exists as a row whether or not a result was recorded.
1 / 4
company_idcompany_namecategory_idcategory_name
1AlphaTech1Cloud
1AlphaTech2Security
1AlphaTech3AI/ML
2BetaSoft1Cloud
2BetaSoft2Security
2BetaSoft3AI/ML
3GammaSys1Cloud
3GammaSys2Security
3GammaSys3AI/ML
After CROSS JOIN: 9 rows (all combinations)
LEARNING POINTS
Calculating CROSS JOIN row counts: CROSS JOIN produces the number of left-table rows multiplied by the number of right-table rows. Three companies × three categories equals nine rows; 100 companies × 50 categories equals 5,000 rows. Because a CROSS JOIN on large tables can increase row counts explosively, prefilter the required combinations with WHERE or IN, or limit them in a CTE.
Multi-stage fallback with COALESCE: COALESCE(a, b, c) returns the first non-NULL value from left to right, a→b→c. COALESCE(sd.annual_revenue, 0) means “use the recorded value when present; otherwise use 0.” Using standard SQL COALESCE instead of ISNULL, a SQL Server dialect feature, or NVL, an Oracle dialect feature, improves portability.
The ON clause for a composite-key join: USING is unsuitable when the join must compare differently qualified columns or express more complex conditions. In the LEFT JOIN after a CROSS JOIN, explicitly naming both tables' columns in ON is reliable. Specifying multiple columns with AND in ON joins only rows for which both company_id and category_id match.
ANTI-PATTERNS
Forgetting to constrain a CROSS JOIN, creating an unintended Cartesian product: listing tables in FROM without a join condition creates an implicit CROSS JOIN. For example, FROM companies, categories, sales_data produces a three-table Cartesian product of 3×3×6=54 rows. Always specify a join condition for an ordinary JOIN.
Aggregating NULL as-is: if you apply SUM or AVG without converting NULL to 0 with COALESCE, NULL is excluded from the calculation: AVG(NULL, NULL, 900) = 900, which differs from treating the missing values as 0. Decide whether to convert explicitly to 0 before aggregation according to the business requirement.
Field Note: Applying competitor comparison matrices in practice
A complete “all companies × all categories” matrix is an ideal format to pass to BI tools such as Tableau, Looker, or Power BI as input for a heat map or bubble chart. Cells filled with 0 make unentered market gaps visible and help prioritize new-entry candidates. The CROSS JOIN pattern is also used with a date dimension: build an “all dates × all companies” skeleton with date_series CROSS JOIN companies, then attach recorded results with a LEFT JOIN to construct a daily trend-analysis table without missing dates.
QUESTION 10
Cumulative-Share Analysis — Test the Pareto Principle (80/20 Rule) with SUM() OVER(ORDER BY)
ROWS UNBOUNDEDSUM OVERCumulative SharePareto Analysis
Background

A running total is implemented by giving SUM() OVER (ORDER BY ...) the window frame ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW. Cumulative share in descending value order is the foundation of Pareto analysis.

SUM(revenue) OVER (
  ORDER BY revenue DESC
  ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- Cumulative total from the first row through the current row
) AS cumulative_revenue
Ratio to the grand total: SUM(revenue) OVER (), with neither PARTITION BY nor ORDER BY, treats every row as one window and returns the grand total. Use it as the denominator to calculate cumulative value ÷ grand total × 100.
Problem

Using annual revenue for six IT competitors, calculate cumulative revenue (cumulative_revenue) and cumulative share (cumulative_share_pct, rounded to one decimal place) in descending revenue order. This Pareto analysis visualizes what percentage of the market is held by the top N companies. Return company_name, annual_revenue, cumulative_revenue, cumulative_share_pct, sorted by annual_revenue descending.

Tables
▸ competitor_revenue
company_nameannual_revenue
AlphaTech4200
BetaSoft2800
GammaSys1500
DeltaNet500
EpsilonSys300
ZetaCloud200

annual_revenue unit: JPY 100 million / Market total: JPY 950 billion

Expected Output

Expected output (annual_revenue descending):

company_nameannual_revenuecumulative_revenuecumulative_share_pct
AlphaTech4200420044.2
BetaSoft2800700073.7
GammaSys1500850089.5
DeltaNet500900094.7
EpsilonSys300930097.9
ZetaCloud2009500100.0

The top two companies, AlphaTech and BetaSoft, hold a cumulative 73.7% share. The top three hold 89.5%, so the third company is where the cumulative share reaches 80%.

Model Answer
SELECT
  company_name,
  annual_revenue,
  SUM(annual_revenue) OVER (
    ORDER BY annual_revenue DESC
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW  -- Cumulative total from the first row through the current row
  ) AS cumulative_revenue,
  ROUND(
    SUM(annual_revenue) OVER (
      ORDER BY annual_revenue DESC
      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
    ) * 100.0 /
    SUM(annual_revenue) OVER (),                      -- Denominator: grand total (OVER() = all rows)
    1
  ) AS cumulative_share_pct
FROM   competitor_revenue
ORDER BY annual_revenue DESC;

/*
  Execution order (logical SQL evaluation order):
  1. FROM competitor_revenue       → Read the rows
  2. SUM(...) OVER ()              → Attach the grand total to every row
  3. SUM(...) OVER (...)           → Calculate the running total
  4. Cumulative value / grand total      → Calculate cumulative share (%)
  5. SELECT                        → Select the columns
  6. ORDER BY annual_revenue DESC  → Sort and return the result
  */
Explanation (table transitions & key points)
SELECT company_name, annual_revenue, SUM(annual_revenue) OVER ( ORDER BY annual_revenue DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) AS cumulative_revenue, ROUND( SUM(annual_revenue) OVER ( ORDER BY annual_revenue DESC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW ) * 100.0 / SUM(annual_revenue) OVER (), 1 ) AS cumulative_share_pct FROM competitor_revenue ORDER BY annual_revenue DESC;
LEGEND
Rows read / loaded
① FROM competitor_revenue
FROM competitor_revenueRead the six rows in competitor_revenue. SUM() OVER () will attach the grand total, while SUM() OVER (ORDER BY DESC ROWS...) will attach the cumulative total to each row.
1 / 4
company_nameannual_revenue
AlphaTech4200
BetaSoft2800
GammaSys1500
DeltaNet500
EpsilonSys300
ZetaCloud200
6 rows read
LEARNING POINTS
The difference between OVER() and OVER(ORDER BY): SUM(x) OVER () attaches the fixed grand total to every row. SUM(x) OVER (ORDER BY x DESC ROWS UNBOUNDED...) returns a different cumulative value for each row. Combining them as the former denominator and the latter numerator, the cumulative value, calculates cumulative share in a single query.
Why explicitly write ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW: the default frame for an ordered window is equivalent to RANGE, which includes rows tied with the current row. Writing ROWS makes the intent to accumulate by physical row explicit and reduces the risk of misreading the frame when equal revenue values exist.
Removing duplication with a CTE: this answer writes SUM() OVER (...ROWS...) twice. A CTE lets you write it once and reference its alias, improving maintainability. Removing duplicated complex window-function expressions with a CTE is a production best practice.
ANTI-PATTERNS
Relying on the default frame by omitting ROWS: SUM(annual_revenue) OVER (ORDER BY annual_revenue DESC) does produce a cumulative value, but with tied records its RANGE behavior includes every row tied with the current row in the current frame, which can produce an unintended cumulative value. Specify ROWS BETWEEN explicitly so that RANGE and ROWS are not confused.
Calculating cumulative share in two stages with subqueries: attempting the calculation without SUM() OVER requires a self-join such as FROM t a JOIN t b ON b.revenue >= a.revenue or multiple subqueries, greatly degrading performance. A window function is the first choice for a cumulative aggregate.
Field Note: Applying Pareto analysis and the 80/20 rule to competitive strategy
In this result, the top three companies hold 89.5% of the market, and cumulative share crosses 80% at the third company. Pareto analysis has many competitive-analysis applications. Determining what percentage of top competitors hold 80% of the market helps narrow the targets for concentrated investment of resources. The same SQL pattern can test hypotheses such as “the top 20% of customers generate 80% of revenue” or “the top 20% of features account for 80% of usage.” To identify the top N companies through the 80% crossing automatically, put this aggregate in a CTE or subquery, then use cumulative_share_pct and the preceding row's value in the outer query to detect the crossing boundary.