Track contentsDay 2 of 30

Day 2: Aggregate functions & GROUP BY

Beginner2 hrs 30 minWorks in: SQL Server 2025, SSMS 22
On this page
  1. A. Lesson overview
  2. B. Theory explained clearly
  3. C. Dataset and starting state
  4. D. Worked examples
  5. E. Guided practical lab — Finance’s 2025 monthly sales by channel
  6. F. Independent practice
  7. G. Solutions and explanations
  8. H. Knowledge check
  9. I. Troubleshooting and cleanup
  10. J. Completion checklist, recap and references

By the end of this lesson you can

  • Summarise a table with COUNT, SUM, AVG, MIN and MAX, and predict how each one treats NULLs.
  • Explain the difference between COUNT(*), COUNT(column) and COUNT(DISTINCT column) and choose the right one.
  • Avoid integer averages by converting values before calling AVG.
  • Join Sales.SalesOrder to Sales.SalesOrderLine with INNER JOIN and 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 the SELECT list must appear in GROUP BY.
  • Filter rows with WHERE and groups with HAVING, and explain why each belongs where it does.
  • Produce subtotals and grand totals with ROLLUP, CUBE and GROUPING SETS, and use GROUPING() and GROUPING_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:

  1. a column (or expression) listed in GROUP BY,
  2. an aggregate function, or
  3. 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 StoreID gives one row for each store and one row with StoreID = NULL for the 218 online orders.
  • You can group by an expression, such as YEAR(OrderDate). Repeat the same expression in the SELECT list. You cannot use a SELECT alias in GROUP BY, because GROUP BY is processed before SELECT.
  • GROUP BY does not sort. Add ORDER 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 WHERE for conditions about a single row: o.OrderStatus <> 'Cancelled'.
  • Use HAVING for conditions about a group’s aggregate: HAVING COUNT(*) >= 10.
  • An aggregate in WHERE fails with error 147 (An aggregate may not appear in the WHERE clause…): at that stage no groups exist yet.
  • A SELECT alias in HAVING fails with Invalid column name, because HAVING runs before SELECT. Repeat the aggregate: HAVING SUM(Quantity) >= 40, not HAVING UnitsSold >= 40. (ORDER BY can use the alias.)
  • A row-level condition placed in HAVING instead of WHERE can still work if the column is grouped, but filter rows in WHERE when 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, but CUBE grows quickly: 12 columns already mean 4,096 sets.
  • You may see the older form GROUP BY a, b WITH ROLLUP in existing code. It is non-ISO syntax kept for backward compatibility; write GROUP 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 use COALESCE(col, 'Total') — it cannot tell a real NULL from a subtotal.
  • Sort reliably: ORDER BY GROUPING(col), col puts 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

  1. Do: Join orders to lines, keep 2025 non-cancelled orders, and count.
  2. 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';
  3. Observe: 360, 198, 63741.94.
  4. Why it matters: This is your control total. Every later version of the report must add up to the same 63,741.94.
  5. Confirm: The three values match.

Step 2 — One row per month

  1. Do: Add MONTH(o.OrderDate) to the SELECT list and to GROUP BY.
  2. Run: replace the SELECT list and add GROUP 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;
  3. 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.
  4. Why it matters: Unusual values are worth explaining before you publish a report.
  5. Confirm: 12 rows.

Step 3 — Split by channel

  1. Do: Add o.SalesChannel to both the SELECT list and GROUP BY.
  2. 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;
  3. Observe: 24 rows: every month has both online and store sales.
  4. Why it matters: The grain of the output is now month × channel.
  5. Confirm: 24 rows; January Online = 1321.89, January Store = 2427.68.

Step 4 — Add month subtotals and a year total with ROLLUP

  1. Do: Wrap the grouping columns in ROLLUP.
  2. Run: the same query with only the GROUP BY line 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;
  3. 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.
  4. Why it matters: The numbers are right, but a reader cannot easily tell which rows are totals. Step 5 fixes that.
  5. 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

  1. Do: Use GROUPING() for labels and ordering.
  2. 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;
  3. 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).
  4. Why it matters: ORDER BY can use GROUPING() to put each total directly under its details. CONCAT converts the month number to text automatically.
  5. Confirm: 37 rows; last row = 63741.94.

Step 6 — The same totals, side by side, with conditional aggregation

  1. Do: Many readers prefer one row per month with a column per channel. Replace the channel grouping with SUM(CASE …).

  2. 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);
  3. 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
  4. Why it matters: The same numbers in a reader-friendly shape. In each row OnlineSales + StoreSales = TotalSales, a quick self-check.

  5. 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 AVG of 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 NULL identifies 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 is StoreID = NULL — all 110 online orders again. That row is a real group (GROUPING(o.StoreID) = 0) whose value is NULL, so HAVING removes exactly that row and nothing else.
  • A WHERE o.StoreID IS NOT NULL filter 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 ALL of three separate GROUP BY queries (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

  1. Understanding. SELECT COUNT(*), COUNT(Email) FROM Sales.Customer; returns 40 and 36. Explain the difference.

  2. 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);
  3. 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;
  4. Debugging. A colleague wants customers with more than 20 orders and writes WHERE COUNT(*) > 20 before GROUP BY CustomerID. What happens, and what is the fix?

  5. Query interpretation. After joining Sales.SalesOrder to Sales.SalesOrderLine, a report shows customer 12 with “59 orders”. The order table says 27. What went wrong?

Show the answers
  1. COUNT(*) counts rows; COUNT(Email) counts non-NULL values in Email. Four customers have no email address.

  2. 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 need CUBE. Without ORDER BY, the row order is not guaranteed.

  3. OrderDate is 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 to GROUP BY CustomerID, OrderDate for one row per customer per day.

  4. It fails with error 147: aggregates cannot appear in WHERE, because WHERE filters individual rows before any groups exist. Use GROUP BY CustomerID HAVING COUNT(*) > 20.

  5. After the join, each row is an order line, so COUNT(*) counted 59 lines. Use COUNT(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. AVG of 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 SELECT column must appear in GROUP BY. All NULLs form one group. GROUP BY does not sort.
  • WHERE filters rows before grouping; HAVING filters groups after aggregation. Neither can use SELECT aliases.
  • ROLLUP builds hierarchical subtotals, CUBE every combination, GROUPING SETS exactly the levels you list; () is the grand total.
  • GROUPING() and GROUPING_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)