GROUP BY and HAVING
One summary row per group: GROUP BY on columns and expressions, the order in which SQL Server runs a query, WHERE versus HAVING, counting empty groups correctly, and subtotals with ROLLUP.
GROUP BY sorts the rows into groups that share the same value, and then the aggregates run once per group. You get one result row per group instead of one for the whole table:
SELECT CategoryId, COUNT(*) AS Products, SUM(Stock) AS Units, MAX(Price) AS DearestFROM dbo.ProductsGROUP BY CategoryIdORDER BY CategoryId;CategoryId Products Units Dearest
---------- -------- ----- -------
1 3 117 89.00
2 2 520 7.20
3 2 580 42.00
4 2 150 11.90
5 2 55 54.00
6 1 0 6.80The rule for the SELECT list
SELECT CategoryId, Name, COUNT(*) AS ProductsFROM dbo.ProductsGROUP BY CategoryId;Msg 8120, Level 16, State 1, Line 1 Column 'dbo.Products.Name' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.
SELECT c.CategoryId, c.Name, COUNT(*) AS Products, AVG(p.Price) AS AvgPriceFROM dbo.Products AS pJOIN dbo.Categories AS c ON c.CategoryId = p.CategoryIdGROUP BY c.CategoryId, c.NameORDER BY Products DESC, c.Name;CategoryId Name Products AvgPrice
---------- -------- -------- ---------
1 Tools 3 39.083333
2 Building 2 5.650000
4 Fixings 2 8.200000
5 Paint 2 33.250000
3 Timber 2 24.400000
6 Safety 1 6.800000The order SQL Server runs a query in
| Step | Clause | What it does |
|---|---|---|
FROM / JOIN | ||
WHERE | ||
GROUP BY | ||
HAVING | ||
SELECT | ||
ORDER BY |
WHERE filters rows, HAVING filters groups
SELECT o.CustomerId, COUNT(DISTINCT o.OrderId) AS Orders, SUM(oi.Quantity * oi.UnitPrice) AS SpentFROM dbo.Orders AS oJOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderIdWHERE o.Status = 'Shipped'GROUP BY o.CustomerIdHAVING SUM(oi.Quantity * oi.UnitPrice) > 100ORDER BY Spent DESC;CustomerId Orders Spent
---------- ------ ------
4 2 723.50
6 2 211.85
1 2 150.80
2 1 113.00SELECT CategoryId, COUNT(*) AS ProductsFROM dbo.ProductsWHERE COUNT(*) >= 2GROUP BY CategoryId;Msg 147, Level 15, State 1, Line 3 An aggregate may not appear in the WHERE clause unless it is in a subquery contained in a HAVING clause or a select list, and the column being aggregated is an outer reference.
SELECT CategoryId, COUNT(*) AS ProductsFROM dbo.ProductsGROUP BY CategoryIdHAVING Products >= 2;Msg 207, Level 16, State 1, Line 4 Invalid column name 'Products'.
Grouping by an expression
SELECT DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) AS Month, COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1)ORDER BY Month;Month Orders ---------- ------ 2024-02-01 2 2024-03-01 2 2024-04-01 2 2024-05-01 2 2024-06-01 2
Counting empty groups correctly
SELECT c.Name, COUNT(*) AS CountStar, COUNT(p.ProductId) AS CountProductsFROM dbo.Categories AS cLEFT JOIN dbo.Products AS p ON p.CategoryId = c.CategoryIdGROUP BY c.NameORDER BY c.Name;Warning: Null value is eliminated by an aggregate or other SET operation. Name CountStar CountProducts -------- --------- ------------- Building 2 2 Fixings 2 2 Paint 2 2 Plumbing 1 0 Safety 1 1 Timber 2 2 Tools 3 3
COUNT(*) counts that row and says 1. COUNT(p.ProductId) skips the NULL and says 0, which is the right answer.
Groups of groups
SELECT CustomerId, Status, COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY CustomerId, StatusORDER BY CustomerId, Status;CustomerId Status Orders
---------- --------- ------
1 Shipped 2
2 Pending 1
2 Shipped 1
3 Cancelled 1
4 Shipped 2
6 Shipped 2
8 Pending 1Subtotals with ROLLUP
GROUP BY ROLLUP (a) adds an extra row with the grand total. In that row the grouped column is NULL, and GROUPING(a) returns 1 to tell a total row from a real NULL:
SELECT CASE WHEN GROUPING(Status) = 1 THEN 'All orders' ELSE Status END AS Status, COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY ROLLUP (Status);Status Orders ---------- ------ Cancelled 1 Pending 2 Shipped 7 All orders 10
Show a hint
Show the solution
SELECT p.Name, COUNT(*) AS Lines, SUM(oi.Quantity) AS QtySold, SUM(oi.Quantity * oi.UnitPrice) AS ValueFROM dbo.OrderItems AS oiJOIN dbo.Products AS p ON p.ProductId = oi.ProductIdGROUP BY p.ProductId, p.NameHAVING COUNT(*) > 1ORDER BY Value DESC;Name Lines QtySold Value ------------------- ----- ------- ------ Timber 2x4 2.4 m 2 52 353.60 Plywood sheet 18 mm 2 8 336.00 Cement 25 kg 2 30 212.00 Wood screws (200) 2 7 83.30 Claw hammer 2 2 37.00 Tape measure 8 m 2 3 29.25
Remember
GROUP BY makes one result row per group; aggregates run once per group. Every SELECT column must be grouped or aggregated (Msg 8120). Logical order: FROM, WHERE, GROUP BY, HAVING, SELECT, ORDER BY. WHERE filters rows before grouping; HAVING filters groups after. HAVING cannot use SELECT aliases; ORDER BY can. After an outer join, count a column from the outer table, not *. ROLLUP adds total rows; GROUPING() tells them apart.