<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Grouping and subqueries
All lessons in this course

Lesson 25 of 54 17 minIntermediate

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:

Query.sql
SELECT CategoryId,       COUNT(*)   AS Products,       SUM(Stock) AS Units,       MAX(Price) AS DearestFROM dbo.ProductsGROUP BY CategoryIdORDER BY CategoryId;
Output
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.80
Interactive

Rows into groups

WHERE
SELECT
HAVING
SELECT CategoryId, COUNT(*)
FROM dbo.Products
GROUP BY CategoryId;
1 ToolsCOUNT(*) = 3
Claw hammer Cordless drill Tape measure 8 m
2 BuildingCOUNT(*) = 2
Cement 25 kg Sand 25 kg
3 TimberCOUNT(*) = 2
Timber 2x4 2.4 m Plywood sheet 18 mm
4 FixingsCOUNT(*) = 2
Wood screws (200) Wall plugs (100)
5 PaintCOUNT(*) = 2
Masonry paint 10 L Paint roller set
6 SafetyCOUNT(*) = 1
Safety goggles
CategoryIdCOUNT(*)
113
222
332
442
552
661
GROUP BY puts the 12 products into 6 buckets, one per CategoryId (Plumbing has no products, so no bucket). The aggregate then runs once per bucket, giving one result row each.

The rule for the SELECT list

After grouping, each result row stands for a whole group. So every column in SELECT must be either in the GROUP BY (it has one value per group) or inside an aggregate (it is summarised). Anything else is an error:

Query.sql
SELECT CategoryId, Name, COUNT(*) AS ProductsFROM dbo.ProductsGROUP BY CategoryId;
Output
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.

To show the category name, join Categories and group by the name as well. Grouping by both CategoryId and Name makes the same groups, since each id has one name:

Query.sql
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;
Output
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.800000

The order SQL Server runs a query in

You write SELECT first, but SQL Server works through the clauses in a different order. This logical order explains most of the rules in this lesson:

StepClauseWhat it does
1FROM / JOINcollects the rows
2WHEREkeeps the rows that pass, one row at a time
3GROUP BYputs the rows into groups
4HAVINGkeeps the groups that pass
5SELECTworks out the columns and gives them their aliases
6ORDER BYsorts the result

So WHERE cannot use an aggregate (the groups do not exist yet), and HAVING cannot use a SELECT alias (it has not been given yet). ORDER BY runs last, so it can use either.

WHERE filters rows, HAVING filters groups

Which customers have spent more than 100 on shipped orders? The status test is about single rows, so it goes in WHERE. The spending test is about a group total, so it goes in HAVING:

Query.sql
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;
Output
CustomerId  Orders   Spent
----------  ------  ------
         4       2  723.50
         6       2  211.85
         1       2  150.80
         2       1  113.00

Putting the aggregate test in WHERE fails, because WHERE runs before there are any groups to add up:

Query.sql
SELECT CategoryId, COUNT(*) AS ProductsFROM dbo.ProductsWHERE COUNT(*) >= 2GROUP BY CategoryId;
Output
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.

And HAVING cannot use the alias from SELECT, so repeat the expression:

Query.sql
SELECT CategoryId, COUNT(*) AS ProductsFROM dbo.ProductsGROUP BY CategoryIdHAVING Products >= 2;
Output
Msg 207, Level 16, State 1, Line 4
Invalid column name 'Products'.

Grouping by an expression

You can group by anything you can calculate. Orders per month, using the first day of each month as the group:

Query.sql
SELECT DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1) AS Month,       COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY DATEFROMPARTS(YEAR(OrderDate), MONTH(OrderDate), 1)ORDER BY Month;
Output
Month       Orders
----------  ------
2024-02-01       2
2024-03-01       2
2024-04-01       2
2024-05-01       2
2024-06-01       2

January has no orders, so it has no rows to group and does not appear. A group only exists if at least one row falls into it. The recursive CTE lesson shows how to list every month, including the empty ones.

Counting empty groups correctly

To include categories with no products you need a LEFT JOIN. But then which COUNT you use matters:

Query.sql
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;
Output
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

Plumbing has no products, but the LEFT JOIN still gives it one row, with NULLs in every Products column. COUNT(*) counts that row and says 1. COUNT(p.ProductId) skips the NULL and says 0, which is the right answer.

Groups of groups

GROUP BY with several columns makes one group for each combination that occurs. Orders by customer and status:

Query.sql
SELECT CustomerId, Status, COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY CustomerId, StatusORDER BY CustomerId, Status;
Output
CustomerId  Status     Orders
----------  ---------  ------
         1  Shipped         2
         2  Pending         1
         2  Shipped         1
         3  Cancelled       1
         4  Shipped         2
         6  Shipped         2
         8  Pending         1

Subtotals 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:

Query.sql
SELECT CASE WHEN GROUPING(Status) = 1 THEN 'All orders' ELSE Status END AS Status,       COUNT(*) AS OrdersFROM dbo.OrdersGROUP BY ROLLUP (Status);
Output
Status      Orders
----------  ------
Cancelled        1
Pending          2
Shipped          7
All orders      10
Quick check

You want categories whose products have an average price above 20. Where does AVG(Price) > 20 go?

Quick check

After Categories LEFT JOIN Products, a category with no products shows 1 in one column and 0 in another. Which expression gave the 0?

Practice

For each product that has been ordered more than once (on more than one order line), show its name, the number of lines, the total quantity sold and the total value (Quantity × UnitPrice). Sort by total value, highest first.

Show a hint

Join OrderItems to Products, GROUP BY the product id and name, and use HAVING COUNT(*) > 1.

Show the solution
Solution.sql
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;
Output
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.
0 of 54 lessons completed