Day 2: Aggregate functions & GROUP BY
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — Finance’s 2025 monthly sales by channel
- F. Independent practice
- G. Solutions and explanations
- H. Knowledge check
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Summarise a table with
COUNT,SUM,AVG,MINandMAX, and predict how each one treats NULLs. - Explain the difference between
COUNT(*),COUNT(column)andCOUNT(DISTINCT column)and choose the right one. - Avoid integer averages by converting values before calling
AVG. - Join
Sales.SalesOrdertoSales.SalesOrderLinewithINNER JOINand explain why the result has more rows than there are orders. - Group rows with
GROUP BY, applying the rule that every non-aggregated column in theSELECTlist must appear inGROUP BY. - Filter rows with
WHEREand groups withHAVING, and explain why each belongs where it does. - Produce subtotals and grand totals with
ROLLUP,CUBEandGROUPING SETS, and useGROUPING()andGROUPING_ID()to label and sort the summary rows reliably. - Build side-by-side totals with conditional aggregation (
SUM(CASE …)).
A. Lesson overview
| Week | 1 — Advanced querying foundations |
| Level | Beginner → Intermediate |
| Estimated time | 2 h 30 min essential + 30 min optional stretch |
| Prerequisites | Day 1: SELECT, WHERE, ORDER BY, CASE, NULL handling. RetailLab in its core state |
| Syllabus topic | Aggregate Functions & GROUP BY — advanced aggregation, HAVING, GROUPING SETS, ROLLUP, CUBE |
| Foundations added today | INNER JOIN primer (order headers to order lines); the date-part functions YEAR, MONTH and DATEPART(QUARTER, …) |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Aggregate functions and NULLs | 20 min |
| Part 2 — Primer: joining orders to order lines | 20 min |
| Part 3 — GROUP BY and HAVING | 20 min |
| Part 4 — ROLLUP, CUBE, GROUPING SETS and GROUPING() | 25 min |
| Worked examples | 25 min |
| Guided lab | 20 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. Ridgeway Outdoor’s finance team is preparing the 2025 year-end pack. They need order and sales totals by year, quarter, month, channel and store, with subtotals and a grand total that a reader can trust. Sales managers want to know which customers order most often and which salespeople bring in the most revenue. Every request is a summary: many rows in, a few rows out.
B. Theory explained clearly
Part 1 — Aggregate functions and NULLs
B1. What an aggregate function does
An aggregate function takes a set of values and returns a single value.
| Function | Returns | NULLs | Notes |
|---|---|---|---|
COUNT(*) |
Number of rows | Counted (it counts rows, not values) | Returns int; COUNT_BIG(*) returns bigint for very large tables |
COUNT(expr) |
Number of non-NULL values | Ignored | |
COUNT(DISTINCT expr) |
Number of distinct non-NULL values | Ignored | |
SUM(expr) |
Total | Ignored | Numeric columns only. Returns NULL if there are no non-NULL values |
AVG(expr) |
Mean = sum ÷ number of non-NULL values | Ignored | Result type depends on input type (B3) |
MIN(expr), MAX(expr) |
Smallest / largest | Ignored | Work on numbers, dates and text |
Without GROUP BY, an aggregate query treats the whole table (after WHERE) as one group and returns one row:
USE RetailLab;
GO
SELECT COUNT(*) AS Customers,
COUNT(Email) AS WithEmail,
COUNT(Phone) AS WithPhone
FROM Sales.Customer;
Result: 40, 36, 30. The 4 customers with no email and the 10 with no phone are rows, so COUNT(*) includes them, but they have no value to count, so COUNT(Email) and COUNT(Phone) skip them.
When an aggregate skips NULLs, SSMS may show Warning: Null value is eliminated by an aggregate or other SET operation on the Messages tab. It is information, not an error.
B2. NULLs can change an average
Because AVG divides by the number of non-NULL values, it is not always the same as SUM ÷ COUNT(*):
-- The VALUES clause builds a three-row table on the fly (you will study this syntax on Day 3).
SELECT AVG(v) AS AvgIgnoringNull, -- (10 + 20) / 2 = 15
SUM(v) / COUNT(*) AS SumOverAllRows -- (10 + 20) / 3 = 10
FROM (VALUES (10), (20), (NULL)) AS t(v);
Neither answer is “right” in general. If NULL means unknown, ignoring it (15) is usually correct. If NULL really means zero, write AVG(COALESCE(v, 0)) to say so.
B3. The integer-average trap
The data type AVG returns depends on its input:
| Input type | AVG returns |
|---|---|
tinyint, smallint, int |
int — the fraction is discarded |
bigint |
bigint |
decimal(p, s) |
decimal(38, max(s, 6)) |
float, real |
float |
Sales.SalesOrderLine.Quantity is an int, so AVG(Quantity) returns 1 although the true average is about 1.48. Convert first: AVG(CAST(Quantity AS decimal(10,2))). Worked example 2 shows both.
B4. Revenue in this course: “net sales”
Each order line’s value after discount is
Quantity * UnitPrice * (1 - DiscountPct)
The course calls the sum of these values net sales. In SQL Server, the multiplication returns a decimal with four decimal places, so the lessons display totals with CAST(SUM(…) AS decimal(12,2)). Converting a decimal to fewer decimal places rounds (it does not truncate). Finance also asked for one rule: cancelled orders are excluded from sales. Shipping charges are not part of net sales.
Part 2 — Primer: joining orders to order lines
B5. Why you need a join today
Order dates, channels and statuses live in Sales.SalesOrder (the order header). Quantities and prices live in Sales.SalesOrderLine. To answer “net sales by year”, you need columns from both tables at once. Combining tables is the job of a join. Day 8 covers joins in depth; today you need only INNER JOIN.
B6. INNER JOIN syntax
SELECT o.OrderID,
o.OrderDate,
o.SalesChannel,
l.OrderLineID,
l.ProductID,
l.Quantity,
l.UnitPrice,
l.DiscountPct
FROM Sales.SalesOrder AS o -- o is a table alias
INNER JOIN Sales.SalesOrderLine AS l -- l is a table alias
ON l.OrderID = o.OrderID -- how rows match
WHERE o.OrderID = 1001
ORDER BY l.OrderLineID;
| Part | Meaning |
|---|---|
AS o, AS l |
Table aliases: short names for the tables in this query |
o.OrderDate |
A column qualified with its table alias. Qualify every column in a join; when both tables have a column with the same name (here, OrderID) an unqualified reference fails with Ambiguous column name |
ON l.OrderID = o.OrderID |
The join condition: pair each line with the order it belongs to — the foreign key from Day 1 |
INNER |
Keep only pairs that match. The word INNER is optional; JOIN on its own means inner join |
Result: 2 rows.
| OrderID | OrderDate | SalesChannel | OrderLineID | ProductID | Quantity | UnitPrice | DiscountPct |
|---|---|---|---|---|---|---|---|
| 1001 | 2024-01-01 | Store | 1 | 1 | 2 | 189.00 | 0.05 |
| 1001 | 2024-01-01 | Store | 2 | 6 | 1 | 59.00 | 0.05 |
B7. A join changes the “grain” of your rows
Look at the result again: order 1001’s date and channel appear on both rows. After the join, each row represents an order line, not an order. This is the most important idea in today’s lesson:
| Query | Rows | One row per… |
|---|---|---|
SELECT COUNT(*) FROM Sales.SalesOrder; |
376 | order |
SELECT COUNT(*) FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID; |
702 | order line |
Consequences for aggregation:
- Counting orders after the join needs
COUNT(DISTINCT o.OrderID).COUNT(*)counts lines. - Summing a header column (for example
o.ShippingCost) after the join adds it once per line, which over-states the total for any order with several lines. Sum header values from the header table, not from the joined result. (In this dataset every order charged shipping happens to have a single line, so the total looks right — a coincidence you must not rely on.)
The word for “what one row represents” is grain. Always know the grain of the rows you are aggregating.
B8. Date-part functions used today
| Function | Returns | Example for '2025-11-20' |
|---|---|---|
YEAR(d) |
Year as int |
2025 |
MONTH(d) |
Month number as int |
11 |
DATEPART(QUARTER, d) |
Quarter as int |
4 |
Day 12 covers dates thoroughly. Today these three are enough to group by period.
Part 3 — GROUP BY and HAVING
B9. GROUP BY
GROUP BY splits rows into groups that share the same values in the listed columns and returns one row per group.
SELECT SalesChannel,
COUNT(*) AS Orders
FROM Sales.SalesOrder
GROUP BY SalesChannel
ORDER BY SalesChannel;
Result: Online 218, Store 158.
The golden rule. Once a query groups, every expression in the SELECT list must be one of:
- a column (or expression) listed in
GROUP BY, - an aggregate function, or
- a constant.
Otherwise SQL Server cannot know which of the group’s many values to show, and it raises error 8120:
-- Fails: Column 'Sales.SalesOrder.OrderDate' is invalid in the select list because it is
-- not contained in either an aggregate function or the GROUP BY clause.
SELECT SalesChannel, OrderDate, COUNT(*)
FROM Sales.SalesOrder
GROUP BY SalesChannel;
Fix it by aggregating the column (MAX(OrderDate) AS LatestOrder) or by grouping by it, depending on the question you are asking.
Other rules:
- All NULLs form one group. Grouping orders by
StoreIDgives one row for each store and one row withStoreID = NULLfor the 218 online orders. - You can group by an expression, such as
YEAR(OrderDate). Repeat the same expression in theSELECTlist. You cannot use aSELECTalias inGROUP BY, becauseGROUP BYis processed beforeSELECT. GROUP BYdoes not sort. AddORDER BY.
B10. WHERE filters rows; HAVING filters groups
Extending the logical processing order from Day 1:
| Step | Clause | Works on |
|---|---|---|
| 1 | FROM / JOIN |
Source rows |
| 2 | WHERE |
Individual rows — before grouping |
| 3 | GROUP BY |
Forms groups |
| 4 | HAVING |
Whole groups — after aggregation |
| 5 | SELECT |
Output columns and aliases |
| 6 | ORDER BY |
Final sort |
- Use
WHEREfor conditions about a single row:o.OrderStatus <> 'Cancelled'. - Use
HAVINGfor conditions about a group’s aggregate:HAVING COUNT(*) >= 10. - An aggregate in
WHEREfails with error 147 (An aggregate may not appear in the WHERE clause…): at that stage no groups exist yet. - A
SELECTalias inHAVINGfails with Invalid column name, becauseHAVINGruns beforeSELECT. Repeat the aggregate:HAVING SUM(Quantity) >= 40, notHAVING UnitsSold >= 40. (ORDER BYcan use the alias.) - A row-level condition placed in
HAVINGinstead ofWHEREcan still work if the column is grouped, but filter rows inWHEREwhen you can: it is clearer and it removes rows before the work of grouping.
B11. Conditional aggregation
Putting a CASE expression inside an aggregate counts or sums only the rows that meet a condition. It produces side-by-side totals in one pass:
SELECT YEAR(OrderDate) AS OrderYear,
COUNT(*) AS AllOrders,
SUM(CASE WHEN SalesChannel = 'Online' THEN 1 ELSE 0 END) AS OnlineOrders,
COUNT(CASE WHEN SalesChannel = 'Store' THEN 1 END) AS StoreOrders
FROM Sales.SalesOrder
GROUP BY YEAR(OrderDate)
ORDER BY OrderYear;
| OrderYear | AllOrders | OnlineOrders | StoreOrders |
|---|---|---|---|
| 2024 | 174 | 104 | 70 |
| 2025 | 202 | 114 | 88 |
Both styles work. SUM(CASE … THEN 1 ELSE 0 END) adds ones and zeros. COUNT(CASE … THEN 1 END) relies on the missing ELSE producing NULL, which COUNT ignores. On Day 11 you will compare this technique with PIVOT.
Part 4 — ROLLUP, CUBE, GROUPING SETS and GROUPING()
B12. Grouping sets
A plain GROUP BY a, b produces one level of detail. Reports usually also need subtotals and a grand total. Each level of summary is a grouping set — the list of columns a set of rows is grouped by. () (empty parentheses) is the grouping set for the grand total.
| Syntax | Grouping sets produced | Typical use |
|---|---|---|
GROUP BY GROUPING SETS ((a), (b), ()) |
Exactly the sets you list | Unrelated summaries in one result |
GROUP BY ROLLUP (a, b) |
(a, b), (a), () |
Hierarchies: year → quarter, country → city |
GROUP BY ROLLUP (a, b, c) |
(a, b, c), (a, b), (a), () |
Drops one column from the right at a time |
GROUP BY CUBE (a, b) |
(a, b), (a), (b), () |
Every combination: 2ⁿ sets for n columns |
The result is equivalent to running one GROUP BY query per grouping set and stacking the results with UNION ALL, but it is shorter to write and usually reads the data only once.
Points to watch:
- Column order matters for ROLLUP.
ROLLUP(Year, Quarter)gives subtotals per year.ROLLUP(Quarter, Year)would give subtotals per quarter number across both years — a different report. - Duplicates are not merged.
GROUPING SETS ((), CUBE(a, b))returns two grand-total rows. - Limits. SQL Server allows up to 32 expressions and 4,096 grouping sets in one
GROUP BY. You are unlikely to approach this, butCUBEgrows quickly: 12 columns already mean 4,096 sets. - You may see the older form
GROUP BY a, b WITH ROLLUPin existing code. It is non-ISO syntax kept for backward compatibility; writeGROUP BY ROLLUP(a, b)in new work.
B13. Which NULLs are subtotals? GROUPING() and GROUPING_ID()
In a summary row, every column that has been “rolled up” shows NULL. That creates ambiguity whenever a grouped column can also contain real NULLs. StoreID is NULL for every online order, so in GROUP BY ROLLUP(StoreID) there are two NULL rows: the online orders and the grand total.
| Function | Returns |
|---|---|
GROUPING(col) |
1 if col is aggregated away in this row (a subtotal/total level), 0 if it is a real grouping value — even a real NULL |
GROUPING_ID(a, b, …) |
One integer combining all the flags as binary digits: GROUPING(a) * 2 + GROUPING(b) for two columns |
For ROLLUP(MonthNo, Channel):
| Row type | GROUPING(MonthNo) | GROUPING(Channel) | GROUPING_ID(MonthNo, Channel) |
|---|---|---|---|
| Detail (month + channel) | 0 | 0 | 0 |
| Month subtotal | 0 | 1 | 1 |
| Grand total | 1 | 1 | 3 |
Use these functions to:
- Label summary rows:
CASE WHEN GROUPING(o.SalesChannel) = 1 THEN 'All channels' ELSE o.SalesChannel END. Do not useCOALESCE(col, 'Total')— it cannot tell a real NULL from a subtotal. - Sort reliably:
ORDER BY GROUPING(col), colputs detail rows before their totals. - Filter levels in
HAVING, for example to drop an unwanted group.
The argument of GROUPING() must match an expression in GROUP BY exactly. If you group by YEAR(o.OrderDate), write GROUPING(YEAR(o.OrderDate)), not GROUPING(OrderYear).
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab |
| Tables used | Sales.SalesOrder, Sales.SalesOrderLine, Sales.Customer, Sales.Payment, Inventory.Product |
| Relationship used | Sales.SalesOrderLine.OrderID → Sales.SalesOrder.OrderID (many lines per order) |
| Permissions | SELECT on these tables (your setup account has it) |
| Objects created today | None — Day 2 is read-only |
Relevant columns
| Table | Columns |
|---|---|
Sales.SalesOrder |
OrderID, CustomerID, SalesChannel, StoreID, EmployeeID, OrderDate, ShippedDate, OrderStatus, ShippingCost |
Sales.SalesOrderLine |
OrderLineID, OrderID, ProductID, Quantity, UnitPrice, DiscountPct |
Sales.Customer |
CustomerID, Email, Phone, Country, CustomerSegment |
Sales.Payment |
PaymentID, OrderID, Amount, PaymentMethod |
Inventory.Product |
ProductID, CategoryID, ListPrice |
Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive: drops and rebuilds RetailLab), or run scripts 00 → 03.
Validation. Expected result: 376, 702, 395, 1038, 124384.4345.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Sales.SalesOrder) AS Orders,
(SELECT COUNT(*) FROM Sales.SalesOrderLine) AS OrderLines,
(SELECT COUNT(*) FROM Sales.Payment) AS Payments,
(SELECT SUM(Quantity) FROM Sales.SalesOrderLine) AS UnitsSold,
(SELECT SUM(Quantity * UnitPrice * (1 - DiscountPct))
FROM Sales.SalesOrderLine) AS GrossLineValue;
D. Worked examples
Run USE RetailLab; once in a new query window, then highlight and run each example.
Worked example 1 — Profile the orders table
SELECT COUNT(*) AS Orders,
COUNT(ShippedDate) AS OrdersWithShipDate, -- NULL = not shipped / cancelled
COUNT(EmployeeID) AS OrdersWithSalesperson, -- NULL = online order
COUNT(DISTINCT CustomerID) AS DistinctCustomers,
MIN(OrderDate) AS FirstOrder,
MAX(OrderDate) AS LastOrder,
SUM(ShippingCost) AS ShippingCharged
FROM Sales.SalesOrder;
Expected result: one row.
| Orders | OrdersWithShipDate | OrdersWithSalesperson | DistinctCustomers | FirstOrder | LastOrder | ShippingCharged |
|---|---|---|---|---|---|---|
| 376 | 365 | 158 | 35 | 2024-01-01 | 2025-12-31 | 148.50 |
What it tells you: 11 orders have no ship date (7 cancelled, 4 pending); 158 store orders have a salesperson; only 35 of the 40 customers have ever ordered. One query, seven facts — profiling a table like this is a good habit before analysing it.
Worked example 2 — The integer-average trap
SELECT AVG(Quantity) AS AvgQtyInteger, -- int input: fraction lost
AVG(CAST(Quantity AS decimal(10,2))) AS AvgQtyDecimal, -- decimal(38,6) result
SUM(Quantity) AS UnitsSold,
COUNT(*) AS OrderLines
FROM Sales.SalesOrderLine;
Expected result: 1, 1.478632, 1038, 702. The first column is not rounded — it is truncated. 1038 ÷ 702 = 1.4786…
Worked example 3 — Group by two columns
SELECT SalesChannel,
OrderStatus,
COUNT(*) AS Orders
FROM Sales.SalesOrder
GROUP BY SalesChannel, OrderStatus
ORDER BY SalesChannel, OrderStatus;
Expected result: 5 rows.
| SalesChannel | OrderStatus | Orders |
|---|---|---|
| Online | Cancelled | 7 |
| Online | Delivered | 199 |
| Online | Pending | 4 |
| Online | Shipped | 8 |
| Store | Delivered | 158 |
Only combinations that exist in the data appear: there is no Store / Pending row, because no such orders exist. (On Day 8 you will learn how to show missing combinations with a zero.)
Worked example 4 — Net sales by year (join + group)
SELECT YEAR(o.OrderDate) AS OrderYear,
COUNT(DISTINCT o.OrderID) AS Orders, -- orders
COUNT(*) AS OrderLines, -- joined rows
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled' -- row filter: before grouping
GROUP BY YEAR(o.OrderDate) -- the expression, not the alias
ORDER BY OrderYear; -- ORDER BY may use the alias
Expected result
| OrderYear | Orders | OrderLines | NetSales |
|---|---|---|---|
| 2024 | 171 | 329 | 58612.94 |
| 2025 | 198 | 360 | 63741.94 |
What to notice: Orders and OrderLines differ because of the grain change (B7). The 7 cancelled orders and their 13 lines are excluded by WHERE before any grouping happens: 369 orders and 689 lines remain. Net sales grew by about 8.8% year on year.
Worked example 5 — HAVING on a single table
Which products have sold at least 40 units across all order lines?
SELECT ProductID,
SUM(Quantity) AS UnitsSold
FROM Sales.SalesOrderLine
GROUP BY ProductID
HAVING SUM(Quantity) >= 40 -- repeat the aggregate: the alias does not exist yet
ORDER BY UnitsSold DESC, ProductID; -- the alias is fine here
Expected result: 7 rows — product 9 (84 units), 2 (48), 18 (46), 10 (45), 15 (45), 8 (41) and 1 (40). Product 9 is the gas canister, a cheap consumable bought in multiples. Products 10 and 15 tie on 45 and are ordered by ProductID.
Worked example 6 — HAVING after a join: frequent customers
Which customers have placed 18 or more non-cancelled orders?
SELECT o.CustomerID,
COUNT(DISTINCT o.OrderID) AS Orders,
COUNT(*) AS OrderLines,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY o.CustomerID
HAVING COUNT(DISTINCT o.OrderID) >= 18
ORDER BY Orders DESC, o.CustomerID;
Expected result: 8 rows.
| CustomerID | Orders | OrderLines | NetSales |
|---|---|---|---|
| 1 | 27 | 48 | 9301.71 |
| 12 | 27 | 59 | 8810.01 |
| 18 | 27 | 50 | 8883.52 |
| 6 | 21 | 41 | 7178.50 |
| 26 | 21 | 36 | 6561.86 |
| 13 | 19 | 29 | 6062.85 |
| 4 | 18 | 38 | 5840.29 |
| 23 | 18 | 36 | 5670.92 |
Try the mistake: change the HAVING to COUNT(*) >= 18. Now the query counts lines, and 13 customers qualify instead of 8 — customer 24, for example, has only 9 orders but 20 order lines. Nothing errors; the answer is simply wrong.
Worked example 7 — ROLLUP: quarterly sales with year subtotals and a grand total
SELECT YEAR(o.OrderDate) AS OrderYear,
DATEPART(QUARTER, o.OrderDate) AS OrderQuarter,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY ROLLUP (YEAR(o.OrderDate), DATEPART(QUARTER, o.OrderDate))
ORDER BY GROUPING(YEAR(o.OrderDate)), -- grand total last
OrderYear,
GROUPING(DATEPART(QUARTER, o.OrderDate)), -- year subtotal after its quarters
OrderQuarter;
Expected result: 11 rows (8 quarters + 2 year subtotals + 1 grand total).
| OrderYear | OrderQuarter | Orders | NetSales |
|---|---|---|---|
| 2024 | 1 | 38 | 11625.75 |
| 2024 | 2 | 35 | 13136.90 |
| 2024 | 3 | 44 | 14563.36 |
| 2024 | 4 | 54 | 19286.94 |
| 2024 | NULL | 171 | 58612.94 |
| 2025 | 1 | 43 | 14528.46 |
| 2025 | 2 | 54 | 18057.81 |
| 2025 | 3 | 45 | 12710.98 |
| 2025 | 4 | 56 | 18444.70 |
| 2025 | NULL | 198 | 63741.94 |
| NULL | NULL | 369 | 122354.88 |
What to notice: the subtotal rows have correct order counts because COUNT(DISTINCT …) is recalculated at each level — subtotals are not the sum of the rows above them, they are fresh aggregates over a larger group. Q3 2025 is the weakest 2025 quarter; it includes the August week with no orders.
Worked example 8 — GROUPING() resolves the NULL ambiguity
SELECT StoreID,
GROUPING(StoreID) AS IsTotalRow,
CASE
WHEN GROUPING(StoreID) = 1 THEN 'All orders'
WHEN StoreID IS NULL THEN 'Online (no store)'
ELSE CONCAT('Store ', StoreID)
END AS RowLabel,
COUNT(*) AS Orders
FROM Sales.SalesOrder
GROUP BY ROLLUP (StoreID)
ORDER BY GROUPING(StoreID), StoreID;
Expected result
| StoreID | IsTotalRow | RowLabel | Orders |
|---|---|---|---|
| NULL | 0 | Online (no store) | 218 |
| 1 | 0 | Store 1 | 51 |
| 2 | 0 | Store 2 | 68 |
| 3 | 0 | Store 3 | 39 |
| NULL | 1 | All orders | 376 |
Two rows show StoreID = NULL. Only GROUPING(StoreID) tells them apart. Replacing the CASE with COALESCE(CAST(StoreID AS varchar(10)), 'All orders') would mislabel 218 online orders as the grand total.
Worked example 9 — CUBE: every combination of channel and year
SELECT CASE WHEN GROUPING(SalesChannel) = 1 THEN 'All channels' ELSE SalesChannel END AS Channel,
CASE WHEN GROUPING(YEAR(OrderDate)) = 1 THEN 'All years'
ELSE CAST(YEAR(OrderDate) AS varchar(4)) END AS OrderYear,
GROUPING_ID(SalesChannel, YEAR(OrderDate)) AS GroupingLevel,
COUNT(*) AS Orders
FROM Sales.SalesOrder
GROUP BY CUBE (SalesChannel, YEAR(OrderDate))
ORDER BY GroupingLevel, Channel, OrderYear;
Expected result: 9 rows (2 × 2 detail + 2 channel totals + 2 year totals + 1 grand total).
| Channel | OrderYear | GroupingLevel | Orders |
|---|---|---|---|
| Online | 2024 | 0 | 104 |
| Online | 2025 | 0 | 114 |
| Store | 2024 | 0 | 70 |
| Store | 2025 | 0 | 88 |
| Online | All years | 1 | 218 |
| Store | All years | 1 | 158 |
| All channels | 2024 | 2 | 174 |
| All channels | 2025 | 2 | 202 |
| All channels | All years | 3 | 376 |
GroupingLevel is GROUPING(SalesChannel) * 2 + GROUPING(YEAR(OrderDate)): 0 = detail, 1 = year rolled up, 2 = channel rolled up, 3 = both. Sorting by it groups each level together.
Worked example 10 — GROUPING SETS: two unrelated summaries in one result
Marketing wants customer counts by country and by segment, plus the total, in one table.
SELECT CASE
WHEN GROUPING(Country) = 0 THEN 'Country'
WHEN GROUPING(CustomerSegment) = 0 THEN 'Segment'
ELSE 'Total'
END AS Dimension,
COALESCE(Country, CustomerSegment, N'All customers') AS DimensionValue,
COUNT(*) AS Customers
FROM Sales.Customer
GROUP BY GROUPING SETS ((Country), (CustomerSegment), ())
ORDER BY GROUPING(Country), GROUPING(CustomerSegment), DimensionValue;
Expected result: 13 rows — 9 countries (Denmark 1, France 4, Germany 2, Ireland 4, Italy 1, Spain 1, Sweden 1, United Kingdom 25, United States 1), then 3 segments (Business 8, Consumer 27, VIP 5), then All customers 40.
COALESCE is safe here because neither Country nor CustomerSegment allows NULL, so a NULL can only mean “rolled up”. The Dimension column still uses GROUPING() because that is the reliable test.
E. Guided practical lab — Finance’s 2025 monthly sales by channel
Finance wants 2025 net sales by month, split by channel, with a total for every month and for the year. Build it step by step in one query window.
Step 1 — Establish the rows you will aggregate
- Do: Join orders to lines, keep 2025 non-cancelled orders, and count.
- Run:
USE RetailLab; GO SELECT COUNT(*) AS OrderLines, COUNT(DISTINCT o.OrderID) AS Orders, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled'; - Observe:
360,198,63741.94. - Why it matters: This is your control total. Every later version of the report must add up to the same 63,741.94.
- Confirm: The three values match.
Step 2 — One row per month
- Do: Add
MONTH(o.OrderDate)to theSELECTlist and toGROUP BY. - Run: replace the
SELECTlist and addGROUP BY/ORDER BY:SELECT MONTH(o.OrderDate) AS OrderMonth, COUNT(DISTINCT o.OrderID) AS Orders, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled' GROUP BY MONTH(o.OrderDate) ORDER BY OrderMonth; - Observe: 12 rows. December is the strongest month (27 orders, 9,718.65); August the weakest (10 orders, 2,419.30) — the week of the warehouse system migration.
- Why it matters: Unusual values are worth explaining before you publish a report.
- Confirm: 12 rows.
Step 3 — Split by channel
- Do: Add
o.SalesChannelto both theSELECTlist andGROUP BY. - Run:
SELECT MONTH(o.OrderDate) AS OrderMonth, o.SalesChannel, COUNT(DISTINCT o.OrderID) AS Orders, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled' GROUP BY MONTH(o.OrderDate), o.SalesChannel ORDER BY OrderMonth, o.SalesChannel; - Observe: 24 rows: every month has both online and store sales.
- Why it matters: The grain of the output is now month × channel.
- Confirm: 24 rows; January Online = 1321.89, January Store = 2427.68.
Step 4 — Add month subtotals and a year total with ROLLUP
- Do: Wrap the grouping columns in
ROLLUP. - Run: the same query with only the
GROUP BYline changed:SELECT MONTH(o.OrderDate) AS OrderMonth, o.SalesChannel, COUNT(DISTINCT o.OrderID) AS Orders, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled' GROUP BY ROLLUP (MONTH(o.OrderDate), o.SalesChannel) ORDER BY OrderMonth, o.SalesChannel; - Observe: 37 rows (24 detail + 12 month subtotals + 1 grand total). The subtotal rows show NULL for
SalesChannel, and the grand total — with NULL for both columns — appears first, because NULL sorts first. - Why it matters: The numbers are right, but a reader cannot easily tell which rows are totals. Step 5 fixes that.
- Confirm: 37 rows; the row with two NULLs shows 198 orders and 63741.94 — your control total from step 1.
Step 5 — Label and order the summary rows
- Do: Use
GROUPING()for labels and ordering. - Run: the complete query:
SELECT CASE WHEN GROUPING(MONTH(o.OrderDate)) = 1 THEN 'Year 2025' ELSE CONCAT('Month ', MONTH(o.OrderDate)) END AS Period, CASE WHEN GROUPING(o.SalesChannel) = 1 THEN 'All channels' ELSE o.SalesChannel END AS Channel, CASE GROUPING_ID(MONTH(o.OrderDate), o.SalesChannel) WHEN 0 THEN 'Detail' WHEN 1 THEN 'Month total' WHEN 3 THEN 'Year total' END AS RowType, COUNT(DISTINCT o.OrderID) AS Orders, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS NetSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled' GROUP BY ROLLUP (MONTH(o.OrderDate), o.SalesChannel) ORDER BY GROUPING(MONTH(o.OrderDate)), MONTH(o.OrderDate), GROUPING(o.SalesChannel), o.SalesChannel; - Observe: the first three rows are Month 1 / Online / Detail (5 orders, 1321.89), Month 1 / Store / Detail (8, 2427.68) and Month 1 / All channels / Month total (13, 3749.57). The last row is Year 2025 / All channels / Year total (198, 63741.94).
- Why it matters:
ORDER BYcan useGROUPING()to put each total directly under its details.CONCATconverts the month number to text automatically. - Confirm: 37 rows; last row = 63741.94.
Step 6 — The same totals, side by side, with conditional aggregation
-
Do: Many readers prefer one row per month with a column per channel. Replace the channel grouping with
SUM(CASE …). -
Run:
SELECT CASE WHEN GROUPING(MONTH(o.OrderDate)) = 1 THEN 'Year 2025' ELSE CONCAT('Month ', MONTH(o.OrderDate)) END AS Period, CAST(SUM(CASE WHEN o.SalesChannel = 'Online' THEN l.Quantity * l.UnitPrice * (1 - l.DiscountPct) ELSE 0 END) AS decimal(12,2)) AS OnlineSales, CAST(SUM(CASE WHEN o.SalesChannel = 'Store' THEN l.Quantity * l.UnitPrice * (1 - l.DiscountPct) ELSE 0 END) AS decimal(12,2)) AS StoreSales, CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS decimal(12,2)) AS TotalSales FROM Sales.SalesOrder AS o JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID WHERE o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' AND o.OrderStatus <> 'Cancelled' GROUP BY ROLLUP (MONTH(o.OrderDate)) ORDER BY GROUPING(MONTH(o.OrderDate)), MONTH(o.OrderDate); -
Observe: 13 rows.
Period OnlineSales StoreSales TotalSales Month 1 1321.89 2427.68 3749.57 Month 2 1428.80 2462.80 3891.60 Month 3 4079.82 2807.47 6887.29 Month 4 3164.03 3703.59 6867.62 Month 5 2878.60 2431.88 5310.48 Month 6 2994.18 2885.52 5879.70 Month 7 3774.58 762.00 4536.58 Month 8 1834.75 584.55 2419.30 Month 9 3379.14 2375.95 5755.09 Month 10 2429.77 2208.23 4638.00 Month 11 1006.51 3081.53 4088.04 Month 12 6097.85 3620.80 9718.65 Year 2025 34389.94 29352.01 63741.94 -
Why it matters: The same numbers in a reader-friendly shape. In each row
OnlineSales + StoreSales = TotalSales, a quick self-check. -
Confirm: 13 rows; the Year 2025 row matches the control total.
F. Independent practice
Write each query before looking at section G. Use ORDER BY with a unique tie-breaker.
Task 1 — Price statistics by category (straightforward)
For each CategoryID in Inventory.Product that has at least three products, return CategoryID, Products (count), MinPrice, MaxPrice and AvgPrice (rounded to 2 decimal places). Sort by CategoryID.
Success criteria: 8 rows. Category 2 (tents) has 4 products, min 189.00, max 599.00, average 346.50. Category 10 (navigation) averages 172.00.
Hint
The threshold is about a group, so it goes in HAVING. AVG of a decimal(10,2) column returns six decimal places; CAST it to decimal(10,2).
Task 2 — Payment summary (COUNT variants)
a) In one row, return the number of payments, the number of distinct orders that have been paid, and the total amount received.
b) Then return one row per PaymentMethod with Payments, TotalAmount, AveragePayment (2 decimal places) and LargestPayment, sorted by TotalAmount from highest to lowest.
Success criteria: (a) 395 payments, 364 paid orders, 120861.55. (b) 4 rows, starting with Card (194 payments, 67025.73) and ending with Gift Card.
Hint
Some orders are paid in two instalments, so the number of payments is larger than the number of paid orders.
Task 3 — Salesperson performance in 2025 (join + HAVING)
For store orders placed in 2025, return one row per EmployeeID with Orders, NetSales and AvgOrderValue (net sales ÷ orders, 2 decimal places). Include only salespeople with at least 10 orders. Sort by NetSales, highest first.
Success criteria: 5 rows. Employee 10 leads with 16 orders, 7786.55 net sales and an average order value of 486.66. Employee 15 (9 orders) is excluded.
Hints
- After the join each row is an order line. How do you count orders?
- Average order value is not
AVGof the line amounts: that would be the average line value.
Task 4 — One report, two dimensions (challenging)
Finance wants a single 2025 result (non-cancelled orders) with net sales and order counts:
- one row per channel,
- one row per store — store orders only; there must be no row for the online orders’ NULL
StoreID, - one grand total row.
Return RowLabel (Channel: Online, Channel: Store, Store 1, Store 2, Store 3, All 2025 orders), Orders and NetSales. Show channel rows first, then store rows, then the total.
Success criteria: 6 rows. Store rows: 21 / 7124.35, 38 / 12253.09, 29 / 9974.56. Total: 198 / 63741.94. The three store rows add up to the Channel: Store row (88 orders, 29352.01).
Hints
GROUPING SETS ((o.SalesChannel), (o.StoreID), ()).- Run it first without any filter on groups. How many rows are there, and which one is unwanted?
- A group-level filter belongs in
HAVING.GROUPING(o.StoreID) = 0 AND o.StoreID IS NULLidentifies exactly the unwanted row.
Stretch challenge — Channel mix by year (optional)
Using all non-cancelled orders, produce a CUBE of year × channel showing Orders, NetSales and OnlineSharePct (online net sales as a percentage of net sales in that row, 1 decimal place). Label rolled-up values as All years / All channels, add a Level column (Detail, Year total, Channel total, Grand total) using GROUPING_ID, and sort detail rows first, then year totals, channel totals and the grand total.
Success criteria: 9 rows. Year totals: 2024 = 171 orders, 58612.94, 59.2 %; 2025 = 198 orders, 63741.94, 54.0 %. Grand total: 369 orders, 122354.88, 56.5 %.
Hint
OnlineSharePct = 100.0 * SUM(CASE WHEN online THEN line amount ELSE 0 END) / SUM(line amount). It is 100.0 on every Online row and 0.0 on every Store row — a good sanity check.
G. Solutions and explanations
Show solution 1
SELECT CategoryID,
COUNT(*) AS Products,
MIN(ListPrice) AS MinPrice,
MAX(ListPrice) AS MaxPrice,
CAST(AVG(ListPrice) AS decimal(10,2)) AS AvgPrice
FROM Inventory.Product
GROUP BY CategoryID
HAVING COUNT(*) >= 3
ORDER BY CategoryID;
| CategoryID | Products | MinPrice | MaxPrice | AvgPrice |
|---|---|---|---|---|
| 2 | 4 | 189.00 | 599.00 | 346.50 |
| 3 | 3 | 59.00 | 299.00 | 162.33 |
| 4 | 3 | 6.50 | 79.00 | 43.50 |
| 7 | 3 | 109.00 | 139.00 | 129.00 |
| 8 | 3 | 19.99 | 65.00 | 48.00 |
| 10 | 4 | 14.99 | 349.00 | 172.00 |
| 12 | 3 | 29.00 | 199.00 | 99.00 |
| 15 | 3 | 99.00 | 159.00 | 139.00 |
Reasoning. Categories 11 and 14 have two products each and are removed by HAVING. Category 8’s exact average is 47.996667 and category 10’s is 171.995; CAST rounds both. ROUND(AVG(ListPrice), 2) also rounds correctly but keeps six decimal places on display (48.000000), which is why CAST is preferred for presentation. The category names are in Inventory.Category; you will add them with a second join on Day 8.
Show solution 2
-- (a)
SELECT COUNT(*) AS Payments,
COUNT(DISTINCT OrderID) AS PaidOrders,
SUM(Amount) AS TotalReceived
FROM Sales.Payment;
-- (b)
SELECT PaymentMethod,
COUNT(*) AS Payments,
SUM(Amount) AS TotalAmount,
CAST(AVG(Amount) AS decimal(10,2)) AS AveragePayment,
MAX(Amount) AS LargestPayment
FROM Sales.Payment
GROUP BY PaymentMethod
ORDER BY TotalAmount DESC, PaymentMethod;
(a) 395, 364, 120861.55.
(b)
| PaymentMethod | Payments | TotalAmount | AveragePayment | LargestPayment |
|---|---|---|---|---|
| Card | 194 | 67025.73 | 345.49 | 1577.70 |
| PayPal | 93 | 28684.30 | 308.43 | 1023.26 |
| Bank Transfer | 66 | 22110.28 | 335.00 | 1523.00 |
| Gift Card | 42 | 3041.24 | 72.41 | 860.97 |
Reasoning. 395 − 364 = 31 more payments than paid orders, because split payments put two rows against one order. PaymentMethod is added to ORDER BY as a tie-breaker, although the totals happen to differ. The 364 paid orders are the 376 orders minus 7 cancelled and 5 not yet paid.
Show solution 3
SELECT o.EmployeeID,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
/ COUNT(DISTINCT o.OrderID) AS decimal(10,2)) AS AvgOrderValue
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.SalesChannel = 'Store'
AND o.OrderDate >= '2025-01-01'
AND o.OrderDate < '2026-01-01'
AND o.OrderStatus <> 'Cancelled'
GROUP BY o.EmployeeID
HAVING COUNT(DISTINCT o.OrderID) >= 10
ORDER BY NetSales DESC, o.EmployeeID;
| EmployeeID | Orders | NetSales | AvgOrderValue |
|---|---|---|---|
| 10 | 16 | 7786.55 | 486.66 |
| 6 | 17 | 5856.28 | 344.49 |
| 7 | 19 | 5620.42 | 295.81 |
| 8 | 13 | 3143.36 | 241.80 |
| 9 | 10 | 2953.55 | 295.36 |
Reasoning.
COUNT(DISTINCT o.OrderID)counts orders;COUNT(*)would count lines.- The average order value divides the unrounded sum by the order count and rounds once at the end. Rounding the sum first and then dividing could differ by a penny.
- Store orders are never cancelled in this dataset, so
o.OrderStatus <> 'Cancelled'changes nothing here. Keeping it applies Finance’s rule consistently and protects the query if that ever changes. - Employee 7 has the most orders but ranks third on sales: count and value tell different stories. Employee 16 (4 orders) left in June 2025.
Common mistake: AVG(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) returns the average line value (for employee 10, about 251), not the average order value.
Show solution 4
SELECT CASE
WHEN GROUPING(o.SalesChannel) = 0 THEN CONCAT('Channel: ', o.SalesChannel)
WHEN GROUPING(o.StoreID) = 0 THEN CONCAT('Store ', o.StoreID)
ELSE 'All 2025 orders'
END AS RowLabel,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderDate >= '2025-01-01'
AND o.OrderDate < '2026-01-01'
AND o.OrderStatus <> 'Cancelled'
GROUP BY GROUPING SETS ((o.SalesChannel), (o.StoreID), ())
HAVING NOT (GROUPING(o.StoreID) = 0 AND o.StoreID IS NULL) -- drop the online "store"
ORDER BY GROUPING(o.SalesChannel),
GROUPING(o.StoreID),
o.SalesChannel,
o.StoreID;
| RowLabel | Orders | NetSales |
|---|---|---|
| Channel: Online | 110 | 34389.94 |
| Channel: Store | 88 | 29352.01 |
| Store 1 | 21 | 7124.35 |
| Store 2 | 38 | 12253.09 |
| Store 3 | 29 | 9974.56 |
| All 2025 orders | 198 | 63741.94 |
Reasoning.
- Without
HAVING, the(o.StoreID)grouping set returns 4 rows, one of which isStoreID = NULL— all 110 online orders again. That row is a real group (GROUPING(o.StoreID) = 0) whose value is NULL, soHAVINGremoves exactly that row and nothing else. - A
WHERE o.StoreID IS NOT NULLfilter would be wrong: it removes online rows before grouping, so the Channel: Online row and the grand total would lose 110 orders. - Ordering: channel rows have flags (0, 1), store rows (1, 0) and the total (1, 1), so sorting by the two
GROUPING()values puts them in the required order. - Acceptable alternative:
UNION ALLof three separateGROUP BYqueries (Day 9) gives the same result, at the cost of reading the data three times.
Show the stretch solution
SELECT CASE WHEN GROUPING(YEAR(o.OrderDate)) = 1 THEN 'All years'
ELSE CAST(YEAR(o.OrderDate) AS varchar(4)) END AS OrderYear,
CASE WHEN GROUPING(o.SalesChannel) = 1 THEN 'All channels'
ELSE o.SalesChannel END AS Channel,
CASE GROUPING_ID(YEAR(o.OrderDate), o.SalesChannel)
WHEN 0 THEN 'Detail'
WHEN 1 THEN 'Year total'
WHEN 2 THEN 'Channel total'
WHEN 3 THEN 'Grand total'
END AS Level,
COUNT(DISTINCT o.OrderID) AS Orders,
CAST(SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(12,2)) AS NetSales,
CAST(100.0 * SUM(CASE WHEN o.SalesChannel = 'Online'
THEN l.Quantity * l.UnitPrice * (1 - l.DiscountPct)
ELSE 0 END)
/ SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct))
AS decimal(5,1)) AS OnlineSharePct
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY CUBE (YEAR(o.OrderDate), o.SalesChannel)
ORDER BY GROUPING_ID(YEAR(o.OrderDate), o.SalesChannel),
YEAR(o.OrderDate),
o.SalesChannel;
| OrderYear | Channel | Level | Orders | NetSales | OnlineSharePct |
|---|---|---|---|---|---|
| 2024 | Online | Detail | 101 | 34687.16 | 100.0 |
| 2024 | Store | Detail | 70 | 23925.77 | 0.0 |
| 2025 | Online | Detail | 110 | 34389.94 | 100.0 |
| 2025 | Store | Detail | 88 | 29352.01 | 0.0 |
| 2024 | All channels | Year total | 171 | 58612.94 | 59.2 |
| 2025 | All channels | Year total | 198 | 63741.94 | 54.0 |
| All years | Online | Channel total | 211 | 69077.10 | 100.0 |
| All years | Store | Channel total | 158 | 53277.78 | 0.0 |
| All years | All channels | Grand total | 369 | 122354.88 | 56.5 |
Reasoning. With GROUPING_ID(year, channel), the year is the high bit (2) and the channel the low bit (1). So 1 means “channel rolled up” (a year total) and 2 means “year rolled up” (a channel total) — a frequent source of mislabelling; check one row by hand. 100.0 makes the division decimal rather than integer. The online share fell from 59.2 % to 54.0 %, partly because the Edinburgh store traded for a full year in 2025. A real report would guard the division with NULLIF(…, 0); here every group has sales, so it is safe.
H. Knowledge check
-
Understanding.
SELECT COUNT(*), COUNT(Email) FROM Sales.Customer;returns40and36. Explain the difference. -
Query interpretation. How many rows does this query return, and what are they?
SELECT SalesChannel, OrderStatus, COUNT(*) FROM Sales.SalesOrder GROUP BY ROLLUP (SalesChannel, OrderStatus); -
Debugging. Why does this fail, and what are two different ways to fix it?
SELECT CustomerID, OrderDate, COUNT(*) AS Orders FROM Sales.SalesOrder GROUP BY CustomerID; -
Debugging. A colleague wants customers with more than 20 orders and writes
WHERE COUNT(*) > 20beforeGROUP BY CustomerID. What happens, and what is the fix? -
Query interpretation. After joining
Sales.SalesOrdertoSales.SalesOrderLine, a report shows customer 12 with “59 orders”. The order table says 27. What went wrong?
Show the answers
-
COUNT(*)counts rows;COUNT(Email)counts non-NULL values inEmail. Four customers have no email address. -
8 rows: the 5 existing channel/status combinations (Online: Cancelled 7, Delivered 199, Pending 4, Shipped 8; Store: Delivered 158), 2 channel subtotals (Online 218, Store 158) and 1 grand total (376).
ROLLUP(a, b)produces the grouping sets(a, b),(a)and()— there are no status-only subtotals; those would needCUBE. WithoutORDER BY, the row order is not guaranteed. -
OrderDateis neither grouped nor aggregated, so SQL Server cannot choose one date per customer (error 8120). Decide what the question needs: aggregate it (MIN(OrderDate) AS FirstOrder) for one row per customer, or add it toGROUP BY CustomerID, OrderDatefor one row per customer per day. -
It fails with error 147: aggregates cannot appear in
WHERE, becauseWHEREfilters individual rows before any groups exist. UseGROUP BY CustomerID HAVING COUNT(*) > 20. -
After the join, each row is an order line, so
COUNT(*)counted 59 lines. UseCOUNT(DISTINCT o.OrderID).
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Column ‘…’ is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause. (8120) | A non-aggregated SELECT column is missing from GROUP BY |
Add it to GROUP BY or wrap it in an aggregate — whichever matches the question |
| An aggregate may not appear in the WHERE clause… (147) | Aggregate used in WHERE |
Move the condition to HAVING |
Invalid column name ‘UnitsSold’ in HAVING or GROUP BY |
A SELECT alias used before SELECT is processed |
Repeat the expression |
| Ambiguous column name ‘OrderID’. | Column exists in both joined tables and is not qualified | Prefix with the table alias: o.OrderID |
| Argument 1 of the GROUPING function does not match any of the expressions in the GROUP BY clause. | GROUPING() argument differs from the GROUP BY expression, e.g. an alias |
Use the identical expression, e.g. GROUPING(YEAR(o.OrderDate)) |
Average shows a whole number such as 1 |
AVG over an integer column |
AVG(CAST(col AS decimal(10,2))) |
| Warning: Null value is eliminated by an aggregate… | An aggregated column contains NULLs | Informational. Decide whether NULL should count as zero (COALESCE) or be ignored |
| Divide by zero error encountered. in a ratio | A group’s denominator is zero | Divide by NULLIF(denominator, 0) |
| Order counts or sums look too high after a join | Grain changed to one row per line | COUNT(DISTINCT o.OrderID); sum header columns from the header table |
| Subtotal rows appear at the top or in odd places | NULL sorts first; no GROUPING() in ORDER BY |
ORDER BY GROUPING(col), col |
| Online orders labelled as a total | COALESCE/ISNULL used to label rollup rows on a nullable column |
Label with GROUPING() |
Cleanup. Day 2 is read-only and creates no objects. Leave RetailLab unchanged for Day 3. Optionally save your work as day02_practice.sql.
J. Completion checklist, recap and references
Completion checklist
- The validation query in section C returned 376, 702, 395, 1038 and 124384.4345.
- Worked example 4 shows 171 / 58612.94 for 2024 and 198 / 63741.94 for 2025.
- Your guided-lab report has 37 rows (step 5) and 13 rows (step 6), and both end with 63741.94.
- Tasks 1–4 match the success criteria (8, 1 + 4, 5 and 6 rows).
- You can explain, without notes, why
COUNT(*)after a join can over-count orders.
Recap
- Aggregates collapse many rows into one value; all except
COUNT(*)ignore NULLs.AVGof an integer returns an integer. - A join changes the grain: after joining orders to lines, each row is a line. Count orders with
COUNT(DISTINCT o.OrderID). - Every non-aggregated
SELECTcolumn must appear inGROUP BY. All NULLs form one group.GROUP BYdoes not sort. WHEREfilters rows before grouping;HAVINGfilters groups after aggregation. Neither can useSELECTaliases.ROLLUPbuilds hierarchical subtotals,CUBEevery combination,GROUPING SETSexactly the levels you list;()is the grand total.GROUPING()andGROUPING_ID()distinguish subtotal NULLs from real NULLs — use them to label, sort and filter summary rows.- Conditional aggregation (
SUM(CASE …)) puts several totals side by side in one row.
Next: Day 3 — Subqueries and derived tables, with a LEFT JOIN primer for finding customers who have never ordered.
References (Microsoft Learn)
- Aggregate functions (Transact-SQL)
- COUNT (Transact-SQL)
- AVG (Transact-SQL) — return types by input type
- SELECT – GROUP BY (Transact-SQL) —
ROLLUP,CUBE,GROUPING SETSand their limits - SELECT – HAVING (Transact-SQL)
- GROUPING (Transact-SQL)
- GROUPING_ID (Transact-SQL)
- FROM clause plus JOIN, APPLY, PIVOT (Transact-SQL)
- DATEPART (Transact-SQL)
- Precision, scale, and length (Transact-SQL)