Competitive Analysis — Learn Core Analysis Techniques with Window Functions from the Basics
BasicCompetitive AnalysisWindow FunctionsPostgreSQL Compatible5 questions
QUESTION 1
Market Share Analysis — Calculate Category Share with SUM() OVER (PARTITION BY)
SUM OVERPARTITION BYMarket ShareWindow Functions
Background

Unlike GROUP BY, a window function attaches an aggregate value to each row without collapsing the rows. This syntax is the basic pattern for calculating share within a category, an essential competitive-analysis task.

SUM(sales) OVER (PARTITION BY category)  -- Attach the category total to every row; the row count is unchanged
SUM(sales) OVER ()                          -- Omitting PARTITION BY gives the grand total
The unchanged row count is the key: splitting rows with PARTITION BY still preserves the original number of rows. As a result, the calculation “individual row value ÷ group total” can be completed in a single SELECT.
Problem

Using annual revenue data for four IT software companies, calculate each company's market share (%) within its category. Return category, company_name, annual_revenue, market_share_pct, round the percentage to one decimal place, and sort by category ascending and then share descending.

Tables
▸ companies
company_idcompany_name
1AlphaTech
2BetaSoft
3GammaSys
4DeltaNet
▸ sales_data
company_idcategoryannual_revenue
1Cloud4200
2Cloud2800
3Cloud1500
4Cloud500
1Security1800
2Security2200
3Security900
4Security600

annual_revenue unit: JPY 100 million

Expected Output

Expected output (category ascending → market_share_pct descending):

categorycompany_nameannual_revenuemarket_share_pct
CloudAlphaTech420046.7
CloudBetaSoft280031.1
CloudGammaSys150016.7
CloudDeltaNet5005.6
SecurityBetaSoft220040.0
SecurityAlphaTech180032.7
SecurityGammaSys90016.4
SecurityDeltaNet60010.9

Cloud totals JPY 900 billion, and Security totals JPY 550 billion. BetaSoft takes the lead in Security.

Model Answer
SELECT
  sd.category,
  c.company_name,
  sd.annual_revenue,
  ROUND(
    sd.annual_revenue * 100.0 /
    SUM(sd.annual_revenue) OVER (PARTITION BY sd.category),  -- Use the category total as the denominator
    1
  ) AS market_share_pct
FROM   companies c
JOIN   sales_data sd USING (company_id)    -- Equijoin on company_id
ORDER BY sd.category, market_share_pct DESC;

/*
  Execution order (logical SQL evaluation order):
  1. FROM companies c       → Read the rows
  2. JOIN sales_data sd     → Join matching rows
  3. SUM(...) OVER (...)    → Evaluate the window function without changing the row count
  4. ROUND(...)             → Format market_share_pct
  5. SELECT                 → Evaluate the columns
  6. ORDER BY               → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT sd.category, c.company_name, sd.annual_revenue, ROUND( sd.annual_revenue * 100.0 / SUM(sd.annual_revenue) OVER ( PARTITION BY sd.category), 1 ) AS market_share_pct FROM companies c JOIN sales_data sd USING (company_id) ORDER BY sd.category, market_share_pct DESC;
LEGEND
Rows read / loaded
① FROM (table references)
FROM companies c, sales_data sdRead the two tables used in the analysis. The left side is companies, the company master, and the right side is sales_data, the recorded revenue data.
1 / 5
▸ companies c
company_idcompany_name
1AlphaTech
2BetaSoft
3GammaSys
4DeltaNet
▸ sales_data sd
company_idcategoryannual_revenue
1Cloud4200
2Cloud2800
3Cloud1500
4Cloud500
1Security1800
2Security2200
3Security900
4Security600
companies: 4 rows / sales_data: 8 rows
LEARNING POINTS
The decisive difference between window functions and GROUP BY: SUM with GROUP BY category collapses the rows, leaving one category-total row. SUM() OVER (PARTITION BY category) attaches the aggregate to each row while preserving the row count. This property makes “individual row value ÷ group total” possible in a single SELECT.
Choosing the PARTITION BY granularity: omitting PARTITION BY sd.category treats every row as one window and returns each company's share of the all-category total, JPY 1.45 trillion. Competitive analysis distinguishes “share within the category” from “overall share” according to the requirement, controlled by whether PARTITION BY is present and what granularity it uses.
Casting with 100.0: the 100.0 in annual_revenue * 100.0 is a common way to promote an integer to a non-integer numeric type. Leaving it as the integer 100 can cause integer division in some DBMSs, truncating the decimal part. PostgreSQL's ::numeric cast is another option.
ANTI-PATTERNS
Trying to calculate share with GROUP BY: after aggregating with GROUP BY company_name, the category total needed as the denominator is not available at the same time. A subquery would be required, making the query verbose. SUM() OVER is the first choice for calculating shares.
Using the wrong denominator by omitting PARTITION BY: using the total across all categories as the denominator produces a meaningless share when category revenue scales differ. In competitive analysis, “share of what?” is the most important requirement. Always review the PARTITION BY granularity.
Field Note: Designing the denominator for market-share calculations
In real competitive analysis, your own database rarely contains complete market-wide data. A common pattern is to import reports from Gartner or IDC and public data from industry associations into a market_totals reference table, then LEFT JOIN it to supply the denominator. The PARTITION BY granularity—product category, region, customer segment, or fiscal period—also determines the meaning of the analysis, so agreeing on it with stakeholders before writing SQL is essential to quality.
QUESTION 2
Year-over-Year Analysis — Compare Competitor Growth with a CTE and LAG()
LAGCTEYoY GrowthNULLIF
Background

LAG() is a window function that returns the value from the preceding row in the same partition for the current row. It is ideal for time-series differences such as year-over-year or month-over-month change.

LAG(revenue) OVER (
  PARTITION BY company_name   -- An independent window for each company
  ORDER BY     fiscal_year    -- Ascending year order determines the preceding row
) AS prev_revenue             -- The first row is NULL because no preceding row exists
Prevent division by zero with NULLIF: the growth formula (current - previous) / previous raises a division-by-zero error when the previous value is 0. NULLIF(prev_revenue, 0) returns NULL when the value is 0, and a division involving NULL produces NULL without an error.
Problem

Using annual revenue data for three companies, calculate each company's year-over-year growth rate (YoY %). Use a CTE to attach the previous year's revenue as prev_revenue, then calculate yoy_pct, rounded to one decimal place, in the outer query. Return company_name, fiscal_year, revenue, prev_revenue, yoy_pct, sorted by company_name ascending and then fiscal_year ascending.

Tables
▸ annual_revenue
company_namefiscal_yearrevenue
AlphaTech2022320
AlphaTech2023380
AlphaTech2024420
BetaSoft2022280
BetaSoft2023260
BetaSoft2024310
GammaSys2022150
GammaSys2023180
GammaSys2024220

revenue unit: JPY 100 million

Expected Output

Expected output (company_name ascending → fiscal_year ascending):

company_namefiscal_yearrevenueprev_revenueyoy_pct
AlphaTech2022320NULLNULL
AlphaTech202338032018.8
AlphaTech202442038010.5
BetaSoft2022280NULLNULL
BetaSoft2023260280-7.1
BetaSoft202431026019.2
GammaSys2022150NULLNULL
GammaSys202318015020.0
GammaSys202422018022.2

The first year, 2022, has no previous-year data, so its values are NULL. BetaSoft's revenue fell in 2023, producing a negative growth rate.

Model Answer
WITH base AS (
  SELECT
    company_name,
    fiscal_year,
    revenue,
    LAG(revenue) OVER (
      PARTITION BY company_name    -- An independent window for each company
      ORDER BY     fiscal_year     -- Refer to the preceding row in ascending year order
    ) AS prev_revenue               -- The first year, 2022, is NULL for every company
  FROM   annual_revenue
)
SELECT
  company_name,
  fiscal_year,
  revenue,
  prev_revenue,
  ROUND(
    (revenue - prev_revenue) * 100.0 /
    NULLIF(prev_revenue, 0),         -- Return NULL when prev=0 to prevent division by zero
    1
  ) AS yoy_pct
FROM   base
ORDER BY company_name, fiscal_year;

/*
  Execution order (logical SQL evaluation order):
  1. CTE base    → Define the CTE and evaluate LAG
  2. Outer query:
     FROM base    → Read the rows
     SELECT       → Evaluate the columns and format yoy_pct
     ORDER BY     → Sort and return the result
*/
Explanation (table transitions & key points)
WITH base AS ( SELECT company_name, fiscal_year, revenue, LAG(revenue) OVER ( PARTITION BY company_name ORDER BY fiscal_year ) AS prev_revenue FROM annual_revenue ) SELECT company_name, fiscal_year, revenue, prev_revenue, ROUND( (revenue - prev_revenue) * 100.0 / NULLIF(prev_revenue, 0), 1 ) AS yoy_pct FROM base ORDER BY company_name, fiscal_year;
LEGEND
Rows read / loaded
① CTE: FROM annual_revenue
WITH base AS ( SELECT ... FROM annual_revenue )Read all nine rows from annual_revenue into a CTE. A CTE is a named subquery that later parts of the query can reference.
1 / 4
company_namefiscal_yearrevenue
AlphaTech2022320
AlphaTech2023380
AlphaTech2024420
BetaSoft2022280
BetaSoft2023260
BetaSoft2024310
GammaSys2022150
GammaSys2023180
GammaSys2024220
9 rows read
LEARNING POINTS
Without PARTITION BY, every row belongs to one window: if PARTITION BY company_name is missing, the “previous row” for AlphaTech 2023 can be another company's row in the overall ordering. A cross-company difference has no meaning. Always specify PARTITION BY for a time-series LAG.
Use a CTE for readability and reuse: extracting the LAG result into a CTE under the name prev_revenue keeps the outer query concise. Without the CTE, LAG() OVER(...) would need to be written twice, increasing the risk of mistakes and maintenance cost.
Choosing between LAG() and LEAD(): LAG(revenue, 1) returns the value one row before, while LEAD(revenue, 1) returns the value one row after. The second argument sets the offset, which defaults to 1, and the third sets a default value, such as 0 instead of NULL for the first row.
ANTI-PATTERNS
Using one global LAG without PARTITION BY: with LAG(revenue) OVER (ORDER BY company_name, fiscal_year), the “previous row” for BetaSoft's first year is AlphaTech's final year. A difference across companies is completely meaningless data.
Omitting NULLIF and dividing by zero: if a previous-period revenue is 0, as it might be in a new entrant's first year, the query fails with DIVISION BY ZERO unless NULLIF is present. NULLIF should always accompany this formula as a division-by-zero guard.
Field Note: Growth metrics beyond YoY, including CAGR
Year-over-year growth captures changes one year at a time, but competitive comparisons also commonly use compound annual growth rate (CAGR). Its formula is POWER(final value / initial value, 1.0 / number of years) - 1. In SQL, it can be written as POWER(MAX(revenue) FILTER(WHERE fiscal_year=2024) / MIN(revenue) FILTER(WHERE fiscal_year=2022), 1.0/2) - 1. Because CAGR compares long-term trends without being dominated by a single-year outlier, it is a standard metric in investor-relations materials and strategic analysis.
QUESTION 3
Category Rankings — Rank Competitors with RANK() OVER (PARTITION BY)
RANKDENSE_RANKRankingCompetitor Scores
Background

SQL provides three ranking window functions, and they handle ties differently.

FunctionHow ties are handledExample (two rows tied for second)
RANK()Assigns the same rank and skips the next rank1, 2, 2, 4
DENSE_RANK()Assigns the same rank without skipping the next rank1, 2, 2, 3
ROW_NUMBER()Forces a unique sequence even for ties1, 2, 3, 4
RANK() OVER (
  PARTITION BY category      -- Restart at 1 for each category
  ORDER BY     avg_score DESC  -- Rank by score descending
) AS score_rank
Problem

Using customer-satisfaction scores by category and company, assign a within-category score ranking named score_rank. Return category, company_name, avg_score, review_count, score_rank, sorted by category ascending and then score_rank ascending.

Tables
▸ competitor_scores
company_namecategoryavg_scorereview_count
AlphaTechCloud4.31250
BetaSoftCloud4.1890
GammaSysCloud3.8430
DeltaNetCloud3.5210
BetaSoftSecurity4.5920
AlphaTechSecurity4.0680
GammaSysSecurity3.9310
DeltaNetSecurity3.2150
Expected Output

Expected output (category ascending → score_rank ascending):

categorycompany_nameavg_scorereview_countscore_rank
CloudAlphaTech4.312501
CloudBetaSoft4.18902
CloudGammaSys3.84303
CloudDeltaNet3.52104
SecurityBetaSoft4.59201
SecurityAlphaTech4.06802
SecurityGammaSys3.93103
SecurityDeltaNet3.21504

BetaSoft leads Security with 4.5, giving the category a different ranking structure from Cloud, where AlphaTech leads with 4.3.

Model Answer
SELECT
  category,
  company_name,
  avg_score,
  review_count,
  RANK() OVER (
    PARTITION BY category        -- Restart at 1 for each category
    ORDER BY     avg_score DESC  -- Assign 1, 2, 3, ... from highest to lowest score
  ) AS score_rank
FROM   competitor_scores
ORDER BY category, score_rank;

/*
  Execution order (logical SQL evaluation order):
  1. FROM competitor_scores  → Read the rows
  2. RANK() OVER (...)       → Evaluate the window function without changing the row count
  3. SELECT                  → Evaluate the columns
  4. ORDER BY                → Sort and return the result
*/
Explanation (table transitions & key points)
SELECT category, company_name, avg_score, review_count, RANK() OVER ( PARTITION BY category ORDER BY avg_score DESC ) AS score_rank FROM competitor_scores ORDER BY category, score_rank;
LEGEND
Rows read / loaded
① FROM competitor_scores
FROM competitor_scoresRead all eight rows from competitor_scores. RANK() OVER will next attach a rank within the category to each row.
1 / 4
company_namecategoryavg_scorereview_count
AlphaTechCloud4.31250
BetaSoftCloud4.1890
GammaSysCloud3.8430
DeltaNetCloud3.5210
BetaSoftSecurity4.5920
AlphaTechSecurity4680
GammaSysSecurity3.9310
DeltaNetSecurity3.2150
8 rows read
LEARNING POINTS
Choosing RANK, DENSE_RANK, or ROW_NUMBER: for a column that may contain ties, such as score or revenue, choose RANK when two companies in third place should make the next rank fifth, DENSE_RANK when the next rank should still be fourth, and ROW_NUMBER when every row needs a forced unique order, such as an internal sequence number. DENSE_RANK is often convenient for competitive rankings.
Without ORDER BY, every row has the same rank: if ORDER BY is omitted from RANK() OVER (PARTITION BY category), every row receives the same rank, 1. Ranking requires ORDER BY.
An overall ranking without PARTITION BY: RANK() OVER (ORDER BY avg_score DESC) treats all eight rows as one window and ranks across categories. Choose this form deliberately when it matches the requirement.
ANTI-PATTERNS
Trying to filter RANK directly in WHERE: WHERE RANK() OVER (...) = 1 is a SQL error. Window functions are evaluated after WHERE and therefore cannot be used there directly. To retrieve only the first-ranked row in each category, calculate score_rank in a CTE or subquery, then write WHERE score_rank = 1 in the outer query.
Ignoring how ties are handled: using RANK on production data with many equal avg_score values can produce “1, 2, 2, 4,” with no third place. If the business requirement still needs a third place, choose DENSE_RANK. This issue often appears only in production when test data contains no ties.
Field Note: Switching the PARTITION BY axis to find each company's top category
To identify each company's strongest category, switch the partitioning axis to RANK() OVER (PARTITION BY company_name ORDER BY avg_score DESC) AS company_rank. Put this in a CTE and filter with WHERE company_rank = 1 to produce a list of every company's strongest categories. The ability to change the analytical viewpoint simply by changing the PARTITION BY axis is one of the most powerful properties of window functions.
QUESTION 4
Competitor KPI Pivot — Convert Long Data to Wide Data with CASE WHEN and GROUP BY
CASE WHENGROUP BYPivot AggregationLong to Wide
Background

When data is stored in long format, CASE WHEN, MAX, and GROUP BY can convert, or pivot, it into wide format.

Long format
companykpi_namekpi_value
AlphaTechRevenue (JPY 100M)420
AlphaTechProfit margin (%)22.5

↓ Pivot with CASE WHEN + GROUP BY

Wide format
companyRevenue (JPY 100M)Profit margin (%)
AlphaTech42022.5
MAX(CASE WHEN kpi_name = 'Revenue (JPY 100M)' THEN kpi_value END) AS revenue_100m
Problem

From the long-format competitor_kpi table, pivot each company's KPIs into columns. Return company_name, revenue_100m, op_margin_pct, new_customers_10k, sorted by revenue_100m descending.

Tables
▸ competitor_kpi (long format — 12 rows)
company_namekpi_namekpi_value
AlphaTechRevenue (JPY 100M)420
AlphaTechOperating margin (%)22.5
AlphaTechNew customers (10K)3.2
BetaSoftRevenue (JPY 100M)310
BetaSoftOperating margin (%)18.0
BetaSoftNew customers (10K)2.8
GammaSysRevenue (JPY 100M)220
GammaSysOperating margin (%)15.5
GammaSysNew customers (10K)1.5
DeltaNetRevenue (JPY 100M)95
DeltaNetOperating margin (%)8.0
DeltaNetNew customers (10K)0.7
Expected Output

Expected output (revenue_100m descending):

company_namerevenue_100mop_margin_pctnew_customers_10k
AlphaTech42022.53.2
BetaSoft31018.02.8
GammaSys22015.51.5
DeltaNet958.00.7

The 12 long-format rows are aggregated into 4 wide-format rows, making the competitors' major KPIs easy to compare side by side.

Model Answer
SELECT
  company_name,
  MAX(CASE WHEN kpi_name = 'Revenue (JPY 100M)'    THEN kpi_value END) AS revenue_100m,  -- Pivot long-format rows into columns
  MAX(CASE WHEN kpi_name = 'Operating margin (%)'   THEN kpi_value END) AS op_margin_pct,
  MAX(CASE WHEN kpi_name = 'New customers (10K)' THEN kpi_value END) AS new_customers_10k
FROM   competitor_kpi
GROUP BY company_name
ORDER BY revenue_100m DESC;

/*
  Execution order (logical SQL evaluation order):
  1. FROM competitor_kpi         → Read the long-format data
  2. GROUP BY company_name       → Group the rows by company
  3. CASE + MAX(...)             → Pivot the rows into columns
  4. SELECT                      → Select the columns
  5. ORDER BY revenue_100m DESC  → Sort and return the result
  */
Explanation (table transitions & key points)
SELECT company_name, MAX(CASE WHEN kpi_name = 'Revenue (JPY 100M)' THEN kpi_value END) AS revenue_100m, MAX(CASE WHEN kpi_name = 'Operating margin (%)' THEN kpi_value END) AS op_margin_pct, MAX(CASE WHEN kpi_name = 'New customers (10K)' THEN kpi_value END) AS new_customers_10k FROM competitor_kpi GROUP BY company_name ORDER BY revenue_100m DESC;
LEGEND
Rows read / loaded
① FROM competitor_kpi (12 long-format rows)
FROM competitor_kpiRead all 12 rows from competitor_kpi. Each company has three long-format rows: revenue, operating margin, and new customers. Side-by-side comparison is difficult in this form, so the data needs to be pivoted.
1 / 4
company_namekpi_namekpi_value
AlphaTechRevenue (JPY 100M)420
AlphaTechOperating margin (%)22.5
AlphaTechNew customers (10K)3.2
BetaSoftRevenue (JPY 100M)310
BetaSoftOperating margin (%)18
BetaSoftNew customers (10K)2.8
GammaSysRevenue (JPY 100M)220
GammaSysOperating margin (%)15.5
GammaSysNew customers (10K)1.5
DeltaNetRevenue (JPY 100M)95
DeltaNetOperating margin (%)8
DeltaNetNew customers (10K)0.7
Long format: 12 rows
LEARNING POINTS
Why MAX is used: CASE WHEN places a non-NULL value on only one row per group and leaves NULL on the other two. MAX(NULL, 420, NULL) ignores NULL and returns 420. Because each group contains only one value, MAX equals that value. MIN would behave the same way because there is only one value.
Choosing long or wide format: long format makes it easy to add a KPI type by adding a row, but makes horizontal comparison harder in SQL. Wide format is easier to read in competitive dashboards and analytical reports, so a common pattern is to store in long format and pivot to wide format for analysis.
Be careful with exact kpi_name matches: CASE WHEN kpi_name = 'Revenue (JPY 100M)' returns NULL on every row if spelling, spaces, or character widths differ. In production, normalize with TRIM(kpi_name) or LOWER(), or prevent inconsistent names by using an enum type or a foreign-key constraint.
ANTI-PATTERNS
Using SUM instead of MAX: SUM theoretically returns the same result when every group contains only one value. If a data-quality problem creates multiple rows with the same kpi_name, however, SUM silently adds them together. MAX is the better practice because it expresses the intent clearly.
Forgetting GROUP BY: if GROUP BY is omitted while company_name remains in SELECT, PostgreSQL raises an error because a nonaggregated column is being selected. If company_name is also removed, all 12 rows form one group and each MAX returns the maximum across all companies. A company-level CASE WHEN pivot requires GROUP BY.
Field Note: PostgreSQL crosstab() and dialect differences
PostgreSQL's tablefunc extension provides crosstab() for more concise pivots. Because its output columns must still be defined, CASE WHEN + MAX can be more readable when the KPI types are fixed. Analytical databases such as Redshift, BigQuery, and Snowflake provide PIVOT syntax, but how values are enumerated and the extent of dynamic-pivot support differ by database. Understanding these dialect differences reduces rework during migration.
QUESTION 5
Price Positioning — Classify Competing Products into Price Quartiles with NTILE()
NTILESubqueryPrice PositioningQuartile Analysis
Background

NTILE(n) is a window function that sorts rows with ORDER BY and then distributes them into n buckets. It is useful for price-band analysis, customer segmentation, sales quantiles, and many other tasks.

NTILE(4) OVER (
  ORDER BY monthly_price    -- Sort by price ascending and divide into four buckets
) AS price_quartile         -- 1=lowest, 4=highest
How remainder rows are distributed: when the row count is not divisible by n, one remainder row is assigned to each bucket in order from the first bucket. For 11 rows ÷ 4, the quotient is 2 with a remainder of 3, so buckets 1, 2, and 3 contain three rows, while bucket 4 contains two.
Problem

Classify 11 competing products from four companies into four price quartiles, from Low to High. Calculate the NTILE value as price_quartile in a subquery, then add an English price_segment label in the outer query. Return product_name, company_name, monthly_price, price_quartile, price_segment, sorted by monthly_price ascending.

Tables
▸ competitor_products
product_idproduct_namecompany_namemonthly_price
1AlphaCloud FreeAlphaTech0
2DeltaCloud MiniDeltaNet300
3BetaCloud BasicBetaSoft500
4GammaCloud LiteGammaSys980
5AlphaCloud StdAlphaTech1200
6BetaCloud StdBetaSoft2500
7AlphaCloud ProAlphaTech3800
8GammaCloud ProGammaSys4500
9DeltaCloud EntDeltaNet6000
10BetaCloud EntBetaSoft8000
11AlphaTech EntAlphaTech12000

monthly_price unit: JPY/month

Expected Output

Expected output (monthly_price ascending):

product_namecompany_namemonthly_priceprice_quartileprice_segment
AlphaCloud FreeAlphaTech01Low
DeltaCloud MiniDeltaNet3001Low
BetaCloud BasicBetaSoft5001Low
GammaCloud LiteGammaSys9802Lower-middle
AlphaCloud StdAlphaTech12002Lower-middle
BetaCloud StdBetaSoft25002Lower-middle
AlphaCloud ProAlphaTech38003Upper-middle
GammaCloud ProGammaSys45003Upper-middle
DeltaCloud EntDeltaNet60003Upper-middle
BetaCloud EntBetaSoft80004High
AlphaTech EntAlphaTech120004High

For 11 rows ÷ 4, the remainder is 3, so buckets 1, 2, and 3 contain three rows and bucket 4 contains two. AlphaTech spans every band with a tiered pricing strategy.

Model Answer
SELECT
  product_name,
  company_name,
  monthly_price,
  price_quartile,
  CASE price_quartile
    WHEN 1 THEN 'Low'     -- Q1: lowest-priced group
    WHEN 2 THEN 'Lower-middle'
    WHEN 3 THEN 'Upper-middle'
    WHEN 4 THEN 'High'    -- Q4: highest-priced group
  END AS price_segment
FROM (
  SELECT
    product_name,
    company_name,
    monthly_price,
    NTILE(4) OVER (
      ORDER BY monthly_price  -- Sort by price ascending and divide into four buckets
    ) AS price_quartile
  FROM   competitor_products
) sub
ORDER BY monthly_price;

/*
  Execution order (logical SQL evaluation order):
  1. Subquery sub
  2. Outer query
  */
Explanation (table transitions & key points)
SELECT product_name, company_name, monthly_price, price_quartile, CASE price_quartile WHEN 1 THEN 'Low' WHEN 2 THEN 'Lower-middle' WHEN 3 THEN 'Upper-middle' WHEN 4 THEN 'High' END AS price_segment FROM ( SELECT product_name, company_name, monthly_price, NTILE(4) OVER ( ORDER BY monthly_price ) AS price_quartile FROM competitor_products ) sub ORDER BY monthly_price;
LEGEND
Rows read / loaded
① FROM competitor_products
FROM competitor_productsRead all 11 rows from competitor_products.
1 / 4
product_namecompany_namemonthly_price
AlphaCloud FreeAlphaTech0
DeltaCloud MiniDeltaNet300
BetaCloud BasicBetaSoft500
GammaCloud LiteGammaSys980
AlphaCloud StdAlphaTech1200
BetaCloud StdBetaSoft2500
AlphaCloud ProAlphaTech3800
GammaCloud ProGammaSys4500
DeltaCloud EntDeltaNet6000
BetaCloud EntBetaSoft8000
AlphaTech EntAlphaTech12000
11 rows read
LEARNING POINTS
NTILE's remainder-row rule: dividing 11 rows into four buckets gives 11÷4=2 with a remainder of 3. One remainder row is added to each bucket in order from the first, so buckets 1, 2, and 3 contain three rows and bucket 4 contains two. Without this rule, the differing bucket sizes are difficult to explain.
Reuse the NTILE result through a subquery: an alias from a SELECT list, price_quartile, cannot be referenced directly by another expression in the same SELECT list, so the calculation is staged in a subquery. Extracting NTILE(4) OVER(...) into the subquery is more readable and maintainable than repeating it inside CASE. A CTE can provide the same two-stage structure.
Combining NTILE with PARTITION BY: NTILE(4) OVER (PARTITION BY category ORDER BY monthly_price) calculates price quartiles separately by category. In competitive analysis, this is useful for comparing price positioning within product categories or customer segments.
ANTI-PATTERNS
Behavior when many prices are tied: when many products have the same price, NTILE may split equal-priced products into different buckets because it distributes rows as an ordered sequence. Consider PERCENT_RANK() or CUME_DIST() when tied values must remain in the same bucket.
NTILE bucket numbers express order only: price_quartile=2 does not mean “twice as expensive” as price_quartile=1. The bucket number represents only relative order: first quartile, second quartile, and so on. To segment by absolute price thresholds, use fixed boundaries such as CASE WHEN monthly_price < 1000 THEN 'Low' ....
Field Note: Applying price-positioning analysis in practice
Quartile analysis like this question directly supports visualization of competing product portfolios. Identifying whether your products are concentrated in one price band and which bands competitors leave underserved provides evidence for pricing a new product. The same SQL pattern also implements customer-LTV quartiles to identify the top 25% of customers or quartile analysis of advertising return on investment. In both competitive and customer analysis, dividing a distribution into equal groups for comparison is a fundamental business-SQL technique.