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
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.
| company_id | company_name |
|---|---|
| 1 | AlphaTech |
| 2 | BetaSoft |
| 3 | GammaSys |
| 4 | DeltaNet |
| company_id | category | annual_revenue |
|---|---|---|
| 1 | Cloud | 4200 |
| 2 | Cloud | 2800 |
| 3 | Cloud | 1500 |
| 4 | Cloud | 500 |
| 1 | Security | 1800 |
| 2 | Security | 2200 |
| 3 | Security | 900 |
| 4 | Security | 600 |
annual_revenue unit: JPY 100 million
Expected output (category ascending → market_share_pct descending):
| category | company_name | annual_revenue | market_share_pct |
|---|---|---|---|
| Cloud | AlphaTech | 4200 | 46.7 |
| Cloud | BetaSoft | 2800 | 31.1 |
| Cloud | GammaSys | 1500 | 16.7 |
| Cloud | DeltaNet | 500 | 5.6 |
| Security | BetaSoft | 2200 | 40.0 |
| Security | AlphaTech | 1800 | 32.7 |
| Security | GammaSys | 900 | 16.4 |
| Security | DeltaNet | 600 | 10.9 |
Cloud totals JPY 900 billion, and Security totals JPY 550 billion. BetaSoft takes the lead in Security.
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 */
LEGEND
① 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.| company_id | company_name |
|---|---|
| 1 | AlphaTech |
| 2 | BetaSoft |
| 3 | GammaSys |
| 4 | DeltaNet |
| company_id | category | annual_revenue |
|---|---|---|
| 1 | Cloud | 4200 |
| 2 | Cloud | 2800 |
| 3 | Cloud | 1500 |
| 4 | Cloud | 500 |
| 1 | Security | 1800 |
| 2 | Security | 2200 |
| 3 | Security | 900 |
| 4 | Security | 600 |
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.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.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.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.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.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
(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.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.
| company_name | fiscal_year | revenue |
|---|---|---|
| AlphaTech | 2022 | 320 |
| AlphaTech | 2023 | 380 |
| AlphaTech | 2024 | 420 |
| BetaSoft | 2022 | 280 |
| BetaSoft | 2023 | 260 |
| BetaSoft | 2024 | 310 |
| GammaSys | 2022 | 150 |
| GammaSys | 2023 | 180 |
| GammaSys | 2024 | 220 |
revenue unit: JPY 100 million
Expected output (company_name ascending → fiscal_year ascending):
| company_name | fiscal_year | revenue | prev_revenue | yoy_pct |
|---|---|---|---|---|
| AlphaTech | 2022 | 320 | NULL | NULL |
| AlphaTech | 2023 | 380 | 320 | 18.8 |
| AlphaTech | 2024 | 420 | 380 | 10.5 |
| BetaSoft | 2022 | 280 | NULL | NULL |
| BetaSoft | 2023 | 260 | 280 | -7.1 |
| BetaSoft | 2024 | 310 | 260 | 19.2 |
| GammaSys | 2022 | 150 | NULL | NULL |
| GammaSys | 2023 | 180 | 150 | 20.0 |
| GammaSys | 2024 | 220 | 180 | 22.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.
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 */
LEGEND
① 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.| company_name | fiscal_year | revenue |
|---|---|---|
| AlphaTech | 2022 | 320 |
| AlphaTech | 2023 | 380 |
| AlphaTech | 2024 | 420 |
| BetaSoft | 2022 | 280 |
| BetaSoft | 2023 | 260 |
| BetaSoft | 2024 | 310 |
| GammaSys | 2022 | 150 |
| GammaSys | 2023 | 180 |
| GammaSys | 2024 | 220 |
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.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.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.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.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.SQL provides three ranking window functions, and they handle ties differently.
| Function | How ties are handled | Example (two rows tied for second) |
|---|---|---|
RANK() | Assigns the same rank and skips the next rank | 1, 2, 2, 4 |
DENSE_RANK() | Assigns the same rank without skipping the next rank | 1, 2, 2, 3 |
ROW_NUMBER() | Forces a unique sequence even for ties | 1, 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
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.
| company_name | category | avg_score | review_count |
|---|---|---|---|
| AlphaTech | Cloud | 4.3 | 1250 |
| BetaSoft | Cloud | 4.1 | 890 |
| GammaSys | Cloud | 3.8 | 430 |
| DeltaNet | Cloud | 3.5 | 210 |
| BetaSoft | Security | 4.5 | 920 |
| AlphaTech | Security | 4.0 | 680 |
| GammaSys | Security | 3.9 | 310 |
| DeltaNet | Security | 3.2 | 150 |
Expected output (category ascending → score_rank ascending):
| category | company_name | avg_score | review_count | score_rank |
|---|---|---|---|---|
| Cloud | AlphaTech | 4.3 | 1250 | 1 |
| Cloud | BetaSoft | 4.1 | 890 | 2 |
| Cloud | GammaSys | 3.8 | 430 | 3 |
| Cloud | DeltaNet | 3.5 | 210 | 4 |
| Security | BetaSoft | 4.5 | 920 | 1 |
| Security | AlphaTech | 4.0 | 680 | 2 |
| Security | GammaSys | 3.9 | 310 | 3 |
| Security | DeltaNet | 3.2 | 150 | 4 |
BetaSoft leads Security with 4.5, giving the category a different ranking structure from Cloud, where AlphaTech leads with 4.3.
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 */
LEGEND
① 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.| company_name | category | avg_score | review_count |
|---|---|---|---|
| AlphaTech | Cloud | 4.3 | 1250 |
| BetaSoft | Cloud | 4.1 | 890 |
| GammaSys | Cloud | 3.8 | 430 |
| DeltaNet | Cloud | 3.5 | 210 |
| BetaSoft | Security | 4.5 | 920 |
| AlphaTech | Security | 4 | 680 |
| GammaSys | Security | 3.9 | 310 |
| DeltaNet | Security | 3.2 | 150 |
RANK() OVER (PARTITION BY category), every row receives the same rank, 1. Ranking requires ORDER 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.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.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.When data is stored in long format, CASE WHEN, MAX, and GROUP BY can convert, or pivot, it into wide format.
| Long format | ||
|---|---|---|
| company | kpi_name | kpi_value |
| AlphaTech | Revenue (JPY 100M) | 420 |
| AlphaTech | Profit margin (%) | 22.5 |
↓ Pivot with CASE WHEN + GROUP BY
| Wide format | ||
|---|---|---|
| company | Revenue (JPY 100M) | Profit margin (%) |
| AlphaTech | 420 | 22.5 |
MAX(CASE WHEN kpi_name = 'Revenue (JPY 100M)' THEN kpi_value END) AS revenue_100m
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.
| company_name | kpi_name | kpi_value |
|---|---|---|
| AlphaTech | Revenue (JPY 100M) | 420 |
| AlphaTech | Operating margin (%) | 22.5 |
| AlphaTech | New customers (10K) | 3.2 |
| BetaSoft | Revenue (JPY 100M) | 310 |
| BetaSoft | Operating margin (%) | 18.0 |
| BetaSoft | New customers (10K) | 2.8 |
| GammaSys | Revenue (JPY 100M) | 220 |
| GammaSys | Operating margin (%) | 15.5 |
| GammaSys | New customers (10K) | 1.5 |
| DeltaNet | Revenue (JPY 100M) | 95 |
| DeltaNet | Operating margin (%) | 8.0 |
| DeltaNet | New customers (10K) | 0.7 |
Expected output (revenue_100m descending):
| company_name | revenue_100m | op_margin_pct | new_customers_10k |
|---|---|---|---|
| AlphaTech | 420 | 22.5 | 3.2 |
| BetaSoft | 310 | 18.0 | 2.8 |
| GammaSys | 220 | 15.5 | 1.5 |
| DeltaNet | 95 | 8.0 | 0.7 |
The 12 long-format rows are aggregated into 4 wide-format rows, making the competitors' major KPIs easy to compare side by side.
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 */
LEGEND
① 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.| company_name | kpi_name | kpi_value |
|---|---|---|
| AlphaTech | Revenue (JPY 100M) | 420 |
| AlphaTech | Operating margin (%) | 22.5 |
| AlphaTech | New customers (10K) | 3.2 |
| BetaSoft | Revenue (JPY 100M) | 310 |
| BetaSoft | Operating margin (%) | 18 |
| BetaSoft | New customers (10K) | 2.8 |
| GammaSys | Revenue (JPY 100M) | 220 |
| GammaSys | Operating margin (%) | 15.5 |
| GammaSys | New customers (10K) | 1.5 |
| DeltaNet | Revenue (JPY 100M) | 95 |
| DeltaNet | Operating margin (%) | 8 |
| DeltaNet | New customers (10K) | 0.7 |
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.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.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.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
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.
| product_id | product_name | company_name | monthly_price |
|---|---|---|---|
| 1 | AlphaCloud Free | AlphaTech | 0 |
| 2 | DeltaCloud Mini | DeltaNet | 300 |
| 3 | BetaCloud Basic | BetaSoft | 500 |
| 4 | GammaCloud Lite | GammaSys | 980 |
| 5 | AlphaCloud Std | AlphaTech | 1200 |
| 6 | BetaCloud Std | BetaSoft | 2500 |
| 7 | AlphaCloud Pro | AlphaTech | 3800 |
| 8 | GammaCloud Pro | GammaSys | 4500 |
| 9 | DeltaCloud Ent | DeltaNet | 6000 |
| 10 | BetaCloud Ent | BetaSoft | 8000 |
| 11 | AlphaTech Ent | AlphaTech | 12000 |
monthly_price unit: JPY/month
Expected output (monthly_price ascending):
| product_name | company_name | monthly_price | price_quartile | price_segment |
|---|---|---|---|---|
| AlphaCloud Free | AlphaTech | 0 | 1 | Low |
| DeltaCloud Mini | DeltaNet | 300 | 1 | Low |
| BetaCloud Basic | BetaSoft | 500 | 1 | Low |
| GammaCloud Lite | GammaSys | 980 | 2 | Lower-middle |
| AlphaCloud Std | AlphaTech | 1200 | 2 | Lower-middle |
| BetaCloud Std | BetaSoft | 2500 | 2 | Lower-middle |
| AlphaCloud Pro | AlphaTech | 3800 | 3 | Upper-middle |
| GammaCloud Pro | GammaSys | 4500 | 3 | Upper-middle |
| DeltaCloud Ent | DeltaNet | 6000 | 3 | Upper-middle |
| BetaCloud Ent | BetaSoft | 8000 | 4 | High |
| AlphaTech Ent | AlphaTech | 12000 | 4 | High |
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.
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 */
LEGEND
① FROM competitor_products
FROM competitor_productsRead all 11 rows from competitor_products.| product_name | company_name | monthly_price |
|---|---|---|
| AlphaCloud Free | AlphaTech | 0 |
| DeltaCloud Mini | DeltaNet | 300 |
| BetaCloud Basic | BetaSoft | 500 |
| GammaCloud Lite | GammaSys | 980 |
| AlphaCloud Std | AlphaTech | 1200 |
| BetaCloud Std | BetaSoft | 2500 |
| AlphaCloud Pro | AlphaTech | 3800 |
| GammaCloud Pro | GammaSys | 4500 |
| DeltaCloud Ent | DeltaNet | 6000 |
| BetaCloud Ent | BetaSoft | 8000 |
| AlphaTech Ent | AlphaTech | 12000 |
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.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.CASE WHEN monthly_price < 1000 THEN 'Low' ....