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

Lesson 24 of 54 15 minIntermediate

Aggregates: COUNT, SUM, AVG, MIN and MAX

Turn many rows into one answer: the five everyday aggregate functions, what COUNT(*) and COUNT(column) really count, how NULLs are skipped, the integer AVG trap, and STRING_AGG for building lists.

Every query so far returned one result row per table row (or per matched pair). An aggregate function does the opposite: it reads many rows and returns one value that summarises them.

Query.sql
SELECT COUNT(*)   AS Products,       SUM(Stock) AS UnitsInStock,       MIN(Price) AS Cheapest,       MAX(Price) AS Dearest,       AVG(Price) AS AveragePriceFROM dbo.Products;
Output
Products  UnitsInStock  Cheapest  Dearest  AveragePrice
--------  ------------  --------  -------  ------------
      12          1422      4.10    89.00     22.254166
FunctionReturnsWorks on
COUNT(*)the number of rowsany rows
COUNT(col)the number of rows where col is not NULLany type
SUM(col)the totalnumbers
AVG(col)the mean: SUM ÷ COUNT(col)numbers
MIN(col) / MAX(col)the smallest / largest valuenumbers, text and dates

What COUNT counts

COUNT(*) counts rows. COUNT(column) counts the rows where that column has a value, so it skips NULLs. COUNT(DISTINCT column) counts the different values:

Query.sql
SELECT COUNT(*)             AS Customers,       COUNT(City)          AS WithCity,       COUNT(DISTINCT City) AS Cities,       COUNT(Email)         AS WithEmail,       COUNT(Phone)         AS WithPhoneFROM dbo.Customers;
Output
Warning: Null value is eliminated by an aggregate or other SET operation.

Customers  WithCity  Cities  WithEmail  WithPhone
---------  --------  ------  ---------  ---------
        8         7       4          6          5

The warning above the result is SQL Server telling you it ignored some NULLs. It is not an error, and SSMS shows it on the Messages tab.

Interactive

What each aggregate counts

AshaLeeds BenYork ChloeLeeds DevBradford EllaNULL FaridYork GraceLeeds ZoëHarrogate
NULL: skipped by every aggregate except COUNT(*) repeated value: counted once by COUNT(DISTINCT)
COUNT(*)COUNT(City)COUNT(DISTINCT City)MIN(City)MAX(City)
1874BradfordYork
8 rows, 1 of them NULL. COUNT(*) counts rows (8), COUNT(City) counts values (7) and COUNT(DISTINCT City) counts different values (4). MIN and MAX compare text alphabetically and ignore the NULLs.

Every aggregate except COUNT(*) skips NULLs

SUM, AVG, MIN and MAX ignore NULLs, as if those rows were not there. That is usually what you want, but it changes the meaning of AVG. Here a table of delivery charges has one charge not yet known:

Query.sql
CREATE TABLE #Charges (OrderId int, Charge decimal(6,2) NULL);INSERT INTO #Charges VALUES (1, 5.00), (2, 10.00), (3, NULL), (4, 9.00); SELECT AVG(Charge)              AS AvgKnown,       SUM(Charge) / COUNT(*)   AS AvgOverAll,       AVG(ISNULL(Charge, 0))   AS AvgNullAsZeroFROM #Charges;
Output
Warning: Null value is eliminated by an aggregate or other SET operation.

AvgKnown  AvgOverAll  AvgNullAsZero
--------  ----------  -------------
8.000000    6.000000       6.000000

AVG(Charge) divides 24 by the 3 known charges. Dividing by all 4 rows, or treating the missing charge as 0, gives 6. Decide which question you are asking: “the average of the charges we know” or “the average per order”.

The integer AVG trap

AVG returns the same type as its input. Stock is an int, so the average is an int too, and the fraction is thrown away:

Query.sql
SELECT SUM(Stock)                      AS Total,       COUNT(*)                        AS Products,       AVG(Stock)                      AS IntAverage,       AVG(CAST(Stock AS decimal(9,2))) AS RealAverageFROM dbo.Products;
Output
Total  Products  IntAverage  RealAverage
-----  --------  ----------  -----------
 1422        12         118   118.500000

Aggregates over a filter or a join

WHERE runs before the aggregate, so it picks which rows are summarised. Joins work the same way. This totals the value of every shipped order line:

Query.sql
SELECT COUNT(*)                     AS Lines,       COUNT(DISTINCT o.OrderId)    AS Orders,       SUM(oi.Quantity * oi.UnitPrice) AS RevenueFROM dbo.Orders AS oJOIN dbo.OrderItems AS oi ON oi.OrderId = o.OrderIdWHERE o.Status = 'Shipped';
Output
Lines  Orders  Revenue
-----  ------  -------
   14       7  1199.15

MIN and MAX also work on text (alphabetical order) and dates (earliest and latest):

Query.sql
SELECT MIN(OrderDate) AS FirstOrder,       MAX(OrderDate) AS LastOrder,       DATEDIFF(day, MIN(OrderDate), MAX(OrderDate)) AS DaysTrading,       MIN(Status)    AS FirstStatusAlphabeticallyFROM dbo.Orders;
Output
FirstOrder  LastOrder   DaysTrading  FirstStatusAlphabetically
----------  ----------  -----------  -------------------------
2024-02-03  2024-06-12          130  Cancelled

When there are no rows

An aggregate over zero rows still returns one row. COUNT says 0, but SUM, AVG, MIN and MAX have nothing to work with and return NULL. Plumbing has no products:

Query.sql
SELECT COUNT(*) AS Products, SUM(Stock) AS Units, MAX(Price) AS DearestFROM dbo.ProductsWHERE CategoryId = 7;
Output
Products  Units  Dearest
--------  -----  -------
       0   NULL     NULL

Building a list with STRING_AGG

STRING_AGG (SQL Server 2017+) joins the values of many rows into one string, with a separator. WITHIN GROUP (ORDER BY …) sets the order of the list:

Query.sql
SELECT STRING_AGG(Name, N', ') WITHIN GROUP (ORDER BY Name) AS OutOfStockFROM dbo.ProductsWHERE Stock = 0;
Output
OutOfStock
--------------------------------
Safety goggles, Wall plugs (100)

Aggregates and plain columns do not mix

An aggregate collapses all rows into one, so a plain column has no single value to show beside it. Which product name should go next to MAX(Price)? SQL Server refuses to guess:

Query.sql
SELECT Name, MAX(Price) AS DearestFROM dbo.Products;
Output
Msg 8120, Level 16, State 1, Line 1
Column 'dbo.Products.Name' is invalid in the select list because it is not contained in either an aggregate function or the GROUP BY clause.

There are two fixes, depending on the question. If you want one result per category, that is GROUP BY, the next lesson. If you want the name of the dearest product, find the maximum first and then the row that has it, which is a subquery, two lessons on.

Quick check

Customers has 8 rows, and Email is NULL in 2 of them. What does SELECT COUNT(Email) FROM dbo.Customers return?

Quick check

A column Score is int, with the values 1, 2 and 2. What does AVG(Score) return?

Practice

For order 4, show in one row: the number of lines, the total number of items (sum of Quantity), the order value (sum of Quantity × UnitPrice), and the product names as a comma-separated list in alphabetical order.

Show a hint

Join OrderItems to Products, filter on OrderId = 4, and use COUNT(*), SUM(…) and STRING_AGG(…) WITHIN GROUP (ORDER BY …).

Show the solution
Solution.sql
SELECT COUNT(*)                        AS Lines,       SUM(oi.Quantity)                AS Items,       SUM(oi.Quantity * oi.UnitPrice) AS OrderValue,       STRING_AGG(p.Name, N', ') WITHIN GROUP (ORDER BY p.Name) AS ProductsFROM dbo.OrderItems AS oiJOIN dbo.Products AS p ON p.ProductId = oi.ProductIdWHERE oi.OrderId = 4;
Output
Lines  Items  OrderValue  Products
-----  -----  ----------  -------------------------------------
    2     46      524.00  Plywood sheet 18 mm, Timber 2x4 2.4 m

Remember

  • An aggregate turns many rows into one value: COUNT, SUM, AVG, MIN, MAX.
  • COUNT(*) counts rows; COUNT(col) skips NULLs; COUNT(DISTINCT col) counts different values.
  • SUM, AVG, MIN and MAX ignore NULLs, which changes what AVG means.
  • AVG of an integer column returns an integer: convert to decimal first.
  • Over zero rows, COUNT returns 0 and the others return NULL.
  • STRING_AGG joins values into a list; WITHIN GROUP sets the order.
  • A plain column cannot sit next to an aggregate without GROUP BY (Msg 8120).
0 of 54 lessons completed