Aggregates and grouping, the T-SQL way
Integer AVG, COUNT(*) against COUNT(col), WHERE against HAVING, ROLLUP with GROUPING(), and STRING_AGG in a fixed order.
After this lesson you can
- Avoid the integer AVG and integer division traps
- Decide whether a condition belongs in WHERE or HAVING
- Produce subtotals with ROLLUP and label them with GROUPING()
- Build an ordered list per group with STRING_AGG ... WITHIN GROUP
GROUP BY collapses rows that share the grouping columns into one row,
and every column in the SELECT list must then be either a grouping
column or inside an aggregate. SQL Server enforces that strictly (error
8120) and never picks an arbitrary value for you, as MySQL does when its
ONLY_FULL_GROUP_BY mode is switched off.
AVG of an integer is an integer
In T-SQL the result type of AVG follows its input. AVG over an
INT column returns an INT, with the fraction thrown away, and
7 / 2 is 3 for the same reason. Make one side decimal first:
AVG(score * 1.0) or AVG(CAST(score AS DECIMAL(10,2))).
CREATE TABLE scores (id INT PRIMARY KEY, score INT NOT NULL);INSERT INTO scores VALUES (1, 1), (2, 2), (3, 2);SELECT AVG(score) AS avg_int, AVG(score * 1.0) AS avg_decimal, CAST(AVG(score * 1.0) AS DECIMAL(10,2)) AS avg_two_places, 7 / 2 AS int_division, 7 / 2.0 AS decimal_divisionFROM scores;WHERE filters rows, HAVING filters groups
The logical order is FROM → WHERE → GROUP BY → HAVING →
SELECT → ORDER BY. A condition on a plain column belongs in
WHERE, so the rows are removed before grouping. A condition on an
aggregate — "teams with at least two members" — can only go in
HAVING, because the count does not exist until the groups do. That
order is also why a column alias defined in SELECT cannot be used in
WHERE or HAVING, only in ORDER BY.
COUNT(*) counts rows. COUNT(col) counts non-NULL values of col.
COUNT(DISTINCT col) counts distinct non-NULL values.
Subtotals: ROLLUP and GROUPING()
GROUP BY ROLLUP (region, product) produces the normal groups, a
subtotal per region and a grand total, in one pass. In the extra rows
the rolled-up columns are NULL, which is ambiguous if the data itself
has NULLs; GROUPING(col) returns 1 exactly when the NULL was made by
the rollup. GROUPING SETS lets you list the combinations you want
explicitly — is shorthand for
— and produces all of them.
The sets need not nest. Below, one pass over a ticket table counts per
team, per priority (across teams) and overall, and the data has
tickets whose priority really is NULL:
CREATE TABLE tickets (id INT PRIMARY KEY, team NVARCHAR(10) NOT NULL, priority NVARCHAR(10) NULL);INSERT INTO tickets VALUES (1, N'Billing', N'High'), (2, N'Billing', NULL), (3, N'Billing', N'Low'), (4, N'Mobile', N'High'), (5, N'Mobile', N'High'), (6, N'Mobile', NULL), (7, N'Web', N'Low');SELECT CASE WHEN GROUPING(team) = 1 THEN N'(any team)' ELSE team END AS team, CASE WHEN GROUPING(priority) = 1 THEN N'(any priority)' WHEN priority IS NULL THEN N'(not set)' ELSE priority END AS priority, COUNT(*) AS ticketsFROM ticketsGROUP BY GROUPING SETS ((team), (priority), ())ORDER BY GROUPING(team), team, GROUPING(priority), priority;STRING_AGG in a defined order
STRING_AGG(name, ', ') concatenates a group's values. Without
WITHIN GROUP (ORDER BY ...) the order of the items is not defined.
It skips NULLs, and it has no DISTINCT option: de-duplicate in a
subquery first.
CREATE TABLE members (id INT PRIMARY KEY, team NVARCHAR(10) NOT NULL, name NVARCHAR(20) NOT NULL);INSERT INTO members VALUES (1, N'Core', N'Nino'), (2, N'Core', N'Ana'), (3, N'Core', N'Luka'), (4, N'Data', N'Mari'), (5, N'Web', N'Giorgi'), (6, N'Web', N'Eka');SELECT team, COUNT(*) AS members, STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY name) AS namesFROM membersGROUP BY teamHAVING COUNT(*) >= 2ORDER BY team;Common mistakes
- Trusting
AVGon anINTcolumn. The first example averaged 1, 2 and 2 and printed 1. - Filtering an aggregate in
WHERE. SQL Server rejects the query (error 147); the condition goes inHAVING. - Filtering a plain column in
HAVING. It works, but every row is grouped first and the intent is harder to read. COUNT(*)when you meant "how many have a value".COUNT(bonus)andCOUNT(*)differ by the number of NULL bonuses.
What interviewers ask
- What is the difference between
WHEREandHAVING?WHEREfilters rows before grouping;HAVINGfilters groups after aggregates are computed. - Why does
AVG(score)return 2 for scores 2 and 3? The input is an integer, so the result is an integer; convert to decimal first. - How do you tell a subtotal row from a group whose key is NULL?
GROUPING(col) = 1marks the rows the rollup added.
Try it yourself
2 visible tests · 2 hidden testsTable employees(id, dept, salary, bonus). salary is an INT;
bonus is an INT that is NULL for anyone without a bonus.
For each department with at least two employees, return:
dept; headcount (number of employees); with_bonus (how many of
them have a bonus); and avg_salary, the true average salary rounded
to 2 decimal places (for example 1000.67, not 1000).
Order by dept.
CREATE TABLE employees (id INT PRIMARY KEY, dept NVARCHAR(10) NOT NULL, salary INT NOT NULL, bonus INT NULL);INSERT INTO employees VALUES (1, N'Eng', 3000, 500), (2, N'Eng', 4000, NULL), (3, N'Eng', 5000, 200), (4, N'Ops', 2000, NULL), (5, N'Ops', 2000, NULL), (6, N'Sales', 2500, 900);Sign up to run the tests, check the hidden ones and save your progress. Sign up