Aggregate Before the Join: Shrinking Rows Early With GROUP BY

Aggregate before the join when a report needs grouped facts rather than individual lines. First declare the grouping grain and the relationship on the other side. Then compare complete results, actual plans, and logical reads.

Loose wooden dowels and tied bundles rest on a slate tray beside four bundles inside an open wooden crate.

Start With the Required Grain

This report needs one total per customer, together with the customer name. The customer key is unique in the first example. Grouping order lines by that key preserves the required detail.

I can make a query look clearer without making its execution cheaper. The optimizer can choose transformations that differ from the written order. A derived table is an expression, not a promise to materialize a smaller table.

Build the Example

Use SQL Server 2022 or later, with compatibility level 160 or higher. Run the setup block below in a new query window, then run the queries that follow in the same window. The setup uses GENERATE_SERIES and creates temporary tables, including deliberate NULL and unmatched cases.

DROP TABLE IF EXISTS #Orders;
DROP TABLE IF EXISTS #Customers;
CREATE TABLE #Orders(OrderId int PRIMARY KEY,CustomerId int NULL,Amount decimal(12,2) NULL,Payload char(300) NOT NULL);
CREATE TABLE #Customers(CustomerId int PRIMARY KEY,CustomerName nvarchar(60) NOT NULL);
INSERT #Orders
SELECT value,value%1000,CONVERT(decimal(12,2),value%100+1),REPLICATE('x',300)
FROM GENERATE_SERIES(1,50000);
INSERT #Orders VALUES(50001,NULL,7,REPLICATE('x',300)),(50002,NULL,3,REPLICATE('x',300)),
(50003,9999,11,REPLICATE('x',300)),(50004,1001,NULL,REPLICATE('x',300));
INSERT #Customers SELECT value,CONCAT(N'Customer ',value) FROM GENERATE_SERIES(0,1001);

SELECT COUNT_BIG(*) AS FactRows,
       (SELECT COUNT_BIG(*) FROM (SELECT CustomerId FROM #Orders GROUP BY CustomerId) g) AS CustomerGroups,
       SUM(Amount) AS RawTotal,
       (SELECT COUNT_BIG(*) FROM #Customers) AS DimensionRows
FROM #Orders;

This setup supplies 50,004 lines and 1,002 customers, and the last query in the block confirms those counts. The regular lines cover customer keys 0 through 999. Customer 1000 has no lines, while customer 1001 has one NULL amount.

Two lines have NULL customer keys, totaling 10.00. Another line references missing customer 9999 and has amount 11.00. Those three lines explain why the inner-join total differs from the raw fact total.

Compare Both Query Shapes

The first query joins lines to customers and then groups. Its customer key remains part of the output. MAXDOP 1 makes the comparison serial without forcing a particular join or aggregate operator.

SET STATISTICS IO ON;
SELECT c.CustomerId,c.CustomerName,SUM(o.Amount) AS TotalAmount
FROM #Customers c JOIN #Orders o ON o.CustomerId=c.CustomerId
GROUP BY c.CustomerId,c.CustomerName OPTION(MAXDOP 1);
SSMS actual plan for join-then-group: Hash Match aggregate, Sort and Merge Join with Customers, returning 1,001 rows.
The written join-then-group query naturally aggregates 50,004 fact rows into 1,003 groups before joining. The Merge Join returns 1,001 rows. Operator percentages are estimated costs, not a timing benchmark. Open the full-size native plan.

The second query declares the customer totals before joining. Keep the same output columns and customer grain. Inspect actual rows at the access, aggregation, and join operators in both plans.

SELECT c.CustomerId,c.CustomerName,x.TotalAmount
FROM #Customers c JOIN
(SELECT CustomerId,SUM(Amount) AS TotalAmount FROM #Orders GROUP BY CustomerId) x
ON x.CustomerId=c.CustomerId OPTION(MAXDOP 1);
SET STATISTICS IO OFF;
SSMS actual plan for group-then-join: the same Hash Match aggregate, Sort and Merge Join chain, returning 1,001 rows.
The written group-then-join query has the same observed physical operator chain and measured logical reads as the first query. This run demonstrates no logical-read saving from the rewrite. Operator percentages are estimated costs, not a timing benchmark. Open the full-size native plan.

Both forms should return 1,001 customer rows and total 2,525,000.00, as the comparison later in this post shows. The all-NULL amount group still produces a customer row with a NULL total. Compare every key and value, including NULLs, before comparing reads.

Group early only when the grain is clear

Equal Grand Totals Can Hide Different Answers

Now duplicate customer 5, including its name, in a dimension without a unique key. Joining first counts those lines twice and groups them into one 600.00 row. Grouping first produces two identical 300.00 rows.

Both results total 2,525,300.00, yet their customer-level rows differ. A grand total cannot establish equivalence. The query below compares output values and duplicate counts, so repeated rows are not discarded.

SELECT CustomerId,CustomerName INTO #CustomerCopies FROM #Customers;
INSERT #CustomerCopies SELECT CustomerId,CustomerName FROM #Customers WHERE CustomerId=5;

WITH UniqueJoinThenGroup AS
(SELECT c.CustomerId,SUM(o.Amount) AS TotalAmount
 FROM #Customers c JOIN #Orders o ON o.CustomerId=c.CustomerId GROUP BY c.CustomerId),
UniqueGroupThenJoin AS
(SELECT c.CustomerId,x.TotalAmount FROM #Customers c JOIN
 (SELECT CustomerId,SUM(Amount) AS TotalAmount FROM #Orders GROUP BY CustomerId) x
 ON x.CustomerId=c.CustomerId),
DuplicateJoinThenGroup AS
(SELECT c.CustomerId,SUM(o.Amount) AS TotalAmount
 FROM #CustomerCopies c JOIN #Orders o ON o.CustomerId=c.CustomerId GROUP BY c.CustomerId),
DuplicateGroupThenJoin AS
(SELECT c.CustomerId,x.TotalAmount FROM #CustomerCopies c JOIN
 (SELECT CustomerId,SUM(Amount) AS TotalAmount FROM #Orders GROUP BY CustomerId) x
 ON x.CustomerId=c.CustomerId)
SELECT N'Unique join then group' AS QueryCase,COUNT_BIG(*) AS ResultRows,SUM(TotalAmount) AS GrandTotal FROM UniqueJoinThenGroup
UNION ALL SELECT N'Unique group then join',COUNT_BIG(*),SUM(TotalAmount) FROM UniqueGroupThenJoin
UNION ALL SELECT N'Duplicate join then group',COUNT_BIG(*),SUM(TotalAmount) FROM DuplicateJoinThenGroup
UNION ALL SELECT N'Duplicate group then join',COUNT_BIG(*),SUM(TotalAmount) FROM DuplicateGroupThenJoin;

SELECT N'Join then group' AS QueryCase,c.CustomerId,SUM(o.Amount) AS TotalAmount,CONVERT(bigint,1) AS Copies
FROM #CustomerCopies c JOIN #Orders o ON o.CustomerId=c.CustomerId
WHERE c.CustomerId=5 GROUP BY c.CustomerId
UNION ALL
SELECT N'Group then join',c.CustomerId,x.TotalAmount,COUNT_BIG(*)
FROM #CustomerCopies c JOIN
 (SELECT CustomerId,SUM(Amount) AS TotalAmount FROM #Orders GROUP BY CustomerId) x
 ON x.CustomerId=c.CustomerId
WHERE c.CustomerId=5 GROUP BY c.CustomerId,x.TotalAmount;
Native SQL Server result grids show four query summaries and one 600.00 row versus two 300.00 rows for customer 5.
Unique keys produce 1,001 rows and total 2,525,000.00 in both forms. A duplicated customer produces one 600.00 row or two 300.00 rows, despite equal grand totals.

Keep Outer Joins and NULLs Deliberate

The LEFT JOIN version keeps all 1,002 customers. Customer 1000 has a NULL total and zero lines. Customer 1001 has a NULL total and one line, which is a different condition.

SELECT c.CustomerId,c.CustomerName,x.TotalAmount,
       COALESCE(x.LineCount,CONVERT(bigint,0)) AS LineCount
FROM #Customers c LEFT JOIN
(SELECT CustomerId,SUM(Amount) AS TotalAmount,COUNT_BIG(*) AS LineCount
 FROM #Orders GROUP BY CustomerId) x ON x.CustomerId=c.CustomerId
ORDER BY c.CustomerId;

COUNT_BIG(*) inside the grouped facts counts stored lines, including a line with a NULL amount. COALESCE supplies zero only when no grouped row matched. SUM ignores NULL amounts.

GROUP BY collects NULL keys into one group, but equality joins do not match NULL keys. The orphan key and NULL-key group stay outside this customer report. Define whether your own report must expose those unmatched facts separately.

Read the Plan Before Claiming a Saving

The 50,004 fact rows contain 1,003 logical customer groups, including the NULL-key group. That is an opportunity for reduction, not a measured performance gain. The optimizer can already place aggregation below an eligible join.

In my SQL Server 2025 run, both natural plans grouped 50,004 fact rows into 1,003 groups before a Merge Join. Each returned 1,001 customer rows.

Each query recorded 2,089 logical reads for facts and eight for customers. Worktable and Workfile logical reads were zero for both queries. Their physical operator chains matched. This rewrite saved no measured logical reads in that run.

Compare the two actual plans and their STATISTICS IO messages from the same demo tables. A matching plan and read count means the rewritten spelling saved no measured reads. Different plans still need complete-result checks and a representative workload.

A selective customer filter changes the comparison, especially when suitable indexes exist. These demo tables have no customer-key index on the facts. It does not prove a result for selective reports, production concurrency, or other aggregates.

When you are done, drop the temporary tables.

DROP TABLE #CustomerCopies;
DROP TABLE #Customers;
DROP TABLE #Orders;

Declare the grain, check every row, and let the actual plan have the last word.

Aggregating early is not a speed trick, it is a grain decision that plans must confirm.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.


Discover more from SQL Authority with Pinal Dave

Subscribe to get the latest posts sent to your email.

SQL Group By, SQL Joins, SQL Performance
Previous Post
SQL SERVER – GUID vs INT – Your Opinion
Next Post
SQL SERVER – Size of Index Table for Each Index – Solution 2

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.