Iñaki Calvo - inakicalvo.dev

SQL Window Functions: Querying Multiple Aggregation Levels at Once

One of the first things you run into with GROUP BY is that it’s destructive. The moment you aggregate, you lose your individual rows. You can see the campaign total, or you can see each daily row, but not both at the same time. If you want both, you reach for a subquery or a CTE to join the aggregated result back to the detail.

Window functions solve this differently. They compute an aggregate across a set of rows, but then hand the result back to each individual row. Nothing collapses. The row you started with is still there, now carrying the group total, the grand total, or any other cross-row calculation you need.

That’s what makes it possible to compute multiple aggregation levels in a single query. It’s one of the most immediately useful things you can learn about window functions.

The examples in this article use a campaign_performance table with this shape. All queries are scoped to March 2026.

campaign_performance (schema)
-- campaign_name | channel | funnel_stage | report_date | impressions | clicks | conversions | spend
-- ──────────────┼────────────┼──────────────┼─────────────┼─────────────┼────────┼─────────────┼──────
-- spring_sale | google_ads | conversion | 2024-03-01 | 12000 | 840 | 42 | 320.00
-- spring_sale | meta_ads | conversion | 2024-03-01 | 18500 | 555 | 28 | 280.00
-- brand_aware | google_ads | awareness | 2024-03-01 | 45000 | 900 | 5 | 150.00
-- ...

The result snippets below use these six rows scoped to March 2026. Six rows is enough to see every pattern in the output without noise getting in the way:

campaign_namechannelfunnel_stagereport_dateimpressionsclicksconversionsspend
brand_awaregoogle_adsawareness2026-03-015000010006170.00
brand_awaregoogle_adsawareness2026-03-15480009605160.00
spring_salegoogle_adsconversion2026-03-011500090045380.00
spring_salegoogle_adsconversion2026-03-151400084042360.00
spring_salemeta_adsconversion2026-03-012200066033310.00
spring_salemeta_adsconversion2026-03-152000060030290.00

The Core Idea: PARTITION BY Controls the “Grain”

The PARTITION BY clause in a window function defines which rows contribute to each calculation. Change the partition, and you change the level of aggregation (without changing anything else about the query).

Row, group, and grand total in one pass
SELECT
campaign_name,
channel,
report_date,
-- Level 1: the row itself (no window function needed)
spend AS daily_spend,
-- Level 2: total spend for this campaign across all channels and dates
SUM(spend) OVER (PARTITION BY campaign_name) AS campaign_total_spend,
-- Level 3: total spend across everything in the result set
SUM(spend) OVER () AS grand_total_spend
FROM campaign_performance
WHERE report_date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY campaign_name, channel, report_date;
campaign_namechannelreport_datedaily_spendcampaign_total_spendgrand_total_spend
brand_awaregoogle_ads2026-03-01170.00330.001670.00
brand_awaregoogle_ads2026-03-15160.00330.001670.00
spring_salegoogle_ads2026-03-01380.001340.001670.00
spring_salegoogle_ads2026-03-15360.001340.001670.00
spring_salemeta_ads2026-03-01310.001340.001670.00
spring_salemeta_ads2026-03-15290.001340.001670.00

The difference between these three columns is entirely in the PARTITION BY:

  • No window function → the row’s own value
  • PARTITION BY campaign_name → one total per campaign, repeated on every row of that campaign
  • OVER () with no partition → one total across the entire result set, repeated on every row

None of these collapse the data. Every row from campaign_performance still appears in the output.


Why This Is Useful: Percent of Total

Once you have multiple aggregation levels on the same row, calculations that previously required a subquery become a single expression.

Percent of campaign total:

Percent of campaign total
SELECT
campaign_name,
channel,
report_date,
spend,
SUM(spend) OVER (PARTITION BY campaign_name) AS campaign_total_spend,
ROUND(
spend * 100.0 / NULLIF(SUM(spend) OVER (PARTITION BY campaign_name), 0),
2) AS day_pct_of_campaign_spend
FROM campaign_performance
WHERE report_date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY campaign_name, day_pct_of_campaign_spend DESC;
campaign_namechannelreport_datespendcampaign_total_spendday_pct_of_campaign_spend
brand_awaregoogle_ads2026-03-01170.00330.0051.52
brand_awaregoogle_ads2026-03-15160.00330.0048.48
spring_salegoogle_ads2026-03-01380.001340.0028.36
spring_salegoogle_ads2026-03-15360.001340.0026.87
spring_salemeta_ads2026-03-01310.001340.0023.13
spring_salemeta_ads2026-03-15290.001340.0021.64

Percent of monthly total:

Percent of monthly total
SELECT
campaign_name,
channel,
report_date,
spend,
SUM(spend) OVER (
PARTITION BY DATE_TRUNC('month', report_date)
) AS month_total_spend,
ROUND(
spend * 100.0 / NULLIF(SUM(spend) OVER (
PARTITION BY DATE_TRUNC('month', report_date)
), 0),
2) AS day_pct_of_month_spend
FROM campaign_performance
WHERE report_date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY report_date, day_pct_of_month_spend DESC;
campaign_namechannelreport_datespendmonth_total_spendday_pct_of_month_spend
spring_salegoogle_ads2026-03-01380.001670.0022.75
spring_salemeta_ads2026-03-01310.001670.0018.56
brand_awaregoogle_ads2026-03-01170.001670.0010.18
spring_salegoogle_ads2026-03-15360.001670.0021.56
spring_salemeta_ads2026-03-15290.001670.0017.37
brand_awaregoogle_ads2026-03-15160.001670.009.58

Note that this partition groups all campaigns together within each month. Each row’s percentage is its share of total spend across every campaign that month, not just its own campaign.

Without window functions, both of these would require a subquery like SELECT SUM(spend) FROM campaign_performance joined or cross-joined back to the main query. The window version does the same thing in one pass, inline.


Stacking All Three Levels

The real payoff is when you need all three levels at once: row detail, group subtotal, and grand total, to build a report that makes sense at a glance.

Channel, campaign, and grand total
SELECT
campaign_name,
channel,
report_date,
spend,
SUM(spend) OVER (
PARTITION BY campaign_name, channel
) AS channel_total_spend,
SUM(spend) OVER (
PARTITION BY campaign_name
) AS campaign_total_spend,
SUM(spend) OVER () AS grand_total_spend,
-- What share of the campaign's budget went to this specific channel?
ROUND(
SUM(spend) OVER (PARTITION BY campaign_name, channel) * 100.0
/ NULLIF(SUM(spend) OVER (PARTITION BY campaign_name), 0),
1) AS channel_pct_of_campaign_spend,
-- What share of total budget does this channel represent?
ROUND(
SUM(spend) OVER (PARTITION BY campaign_name, channel) * 100.0
/ NULLIF(SUM(spend) OVER (), 0),
1) AS channel_pct_of_grand_total_spend
FROM campaign_performance
WHERE report_date BETWEEN '2026-03-01' AND '2026-03-31'
ORDER BY campaign_name, channel, report_date;
campaign_namechannelreport_datespendchannel_total_spendcampaign_total_spendgrand_total_spendchannel_pct_of_campaign_spendchannel_pct_of_grand_total_spend
brand_awaregoogle_ads2026-03-01170.00330.00330.001670.00100.019.8
brand_awaregoogle_ads2026-03-15160.00330.00330.001670.00100.019.8
spring_salegoogle_ads2026-03-01380.00740.001340.001670.0055.244.3
spring_salegoogle_ads2026-03-15360.00740.001340.001670.0055.244.3
spring_salemeta_ads2026-03-01310.00600.001340.001670.0044.835.9
spring_salemeta_ads2026-03-15290.00600.001340.001670.0044.835.9

One query, six different numbers per row: row spend, channel total, campaign total, grand total, and two ratios. No joins or subqueries needed.


A Practical Pattern: Contribution Analysis

One of the most common reporting needs in marketing analytics is understanding which channels, campaigns, or segments are driving results. Window functions make this a one-step operation.

Contribution analysis
WITH campaign_summary AS (
SELECT
campaign_name,
channel,
SUM(spend) AS channel_total_spend,
SUM(conversions) AS channel_total_conversions,
ROUND(SUM(spend) / NULLIF(SUM(conversions), 0), 2) AS channel_cpa
FROM campaign_performance
WHERE report_date BETWEEN '2026-03-01' AND '2026-03-31'
GROUP BY campaign_name, channel
)
SELECT
campaign_name,
channel,
channel_total_spend,
channel_total_conversions,
channel_cpa,
-- What % of this campaign's spend is on this channel?
ROUND(
channel_total_spend * 100.0
/ NULLIF(SUM(channel_total_spend) OVER (PARTITION BY campaign_name), 0),
1) AS channel_pct_of_campaign_spend,
-- How does this channel's CPA compare to the campaign average CPA?
ROUND(
channel_cpa - AVG(channel_cpa) OVER (PARTITION BY campaign_name),
2) AS channel_cpa_vs_campaign_avg,
-- Rank channels within each campaign by conversions
RANK() OVER (
PARTITION BY campaign_name
ORDER BY channel_total_conversions DESC
) AS channel_rank_in_campaign
FROM campaign_summary
ORDER BY campaign_name, channel_rank_in_campaign;
campaign_namechannelchannel_total_spendchannel_total_conversionschannel_cpachannel_pct_of_campaign_spendchannel_cpa_vs_campaign_avgchannel_rank_in_campaign
brand_awaregoogle_ads330.001130.00100.00.001
spring_salegoogle_ads740.00878.5155.2-0.511
spring_salemeta_ads600.00639.5244.80.512

This is the query you want when a stakeholder asks: Which channels are working best? And are we spending proportionally? You can answer all three implicit questions in one result set: absolute performance, share of budget, and relative efficiency.


Things Worth Knowing

The OVER () empty partition is the full result set, not the full table. If your query has a WHERE clause, OVER () totals only the rows that passed the filter. That is why scoping with a WHERE on the date is the right place to define your analysis window: the window functions then operate within that scope automatically. It can still surprise you if you expect OVER () to reflect raw table totals across all time.

Window functions run after WHERE and after GROUP BY, but before HAVING and ORDER BY. This means you can’t filter on a window function result in a WHERE clause. If you need to, wrap the whole query in a CTE and filter in the outer query.

PARTITION BY and GROUP BY are not the same. GROUP BY collapses rows. PARTITION BY defines a scope for the window calculation but leaves every row intact. A query can have both: a GROUP BY to aggregate to some intermediate grain, and then window functions on top of that aggregated result.

Repeated window expressions are fine to write; refactoring them into a CTE is fine too. There’s no strict rule. If the same SUM(...) OVER (PARTITION BY ...) appears three times in your SELECT list, it’s readable, but a CTE that computes it once and names it is often cleaner at scale.


Cheat Sheet

GoalPattern
Row value + group total on same rowSUM(col) OVER (PARTITION BY group_col)
Row value + grand total on same rowSUM(col) OVER ()
Percent of groupcol * 100.0 / NULLIF(SUM(col) OVER (PARTITION BY ...), 0)
Percent of monthly totalcol * 100.0 / NULLIF(SUM(col) OVER (PARTITION BY DATE_TRUNC('month', date_col)), 0)
Rank within groupRANK() OVER (PARTITION BY ... ORDER BY ...)
Channel share + CPA deviation in one queryCTE to aggregate, then window functions on top

The underlying idea is simple: PARTITION BY sets the boundary of each calculation, and OVER () means no boundary. Stack as many of these as you need in one query, each at a different grain, each returning its result back to the original row.

If you have used GROUP BY for years and never touched window functions, this is a good place to start. Pick one query you run regularly that joins back an aggregate, and rewrite it with a window function. The structure becomes clear fast once you see it with your own data.