| |

SQL 29 🛢️ Aggregate Functions: COUNT, SUM, AVG, MIN, MAX

Aggregate functions collapse many rows into a single value. They answer questions like “how many orders were placed,” “what is the total revenue,” “what is the average salary,” “what is the highest price,” and “what is the earliest date.” The five standard aggregates — COUNT, SUM, AVG, MIN, and MAX — are the foundation of reporting and analytics in SQL, and they are almost always used with GROUP BY to produce per-group summaries rather than a single value for the entire table.

The behavior of these functions is not uniform. COUNT behaves differently depending on whether it counts rows or non-NULL values. AVG ignores NULLs, which changes the denominator in ways that surprise people. SUM returns NULL rather than zero when no rows match. These nuances matter because a query that returns the wrong number may still run without error, and the mistake may go unnoticed. Understanding the semantics of each aggregate, and how they interact with NULL and GROUP BY, is essential for writing queries whose results can be trusted.

Key point: Aggregate functions reduce many rows to one value per group. COUNT() counts rows; COUNT(column) counts non-NULL values. All five standard aggregates except COUNT() ignore NULLs. SUM of no rows returns NULL, not zero.


Why aggregate functions exist

The summarization problem. Raw tables contain detail. A table of orders contains one row per order; a table of employees contains one row per employee. Answering “how many,” “how much,” “what is the average,” or “what is the extreme” requires reducing many rows to a single number. Aggregate functions are the mechanism for this reduction, and they are the core of every report, dashboard, and analytical query.

The grouping problem. A single number for the whole table is rarely enough. Managers want totals per department, counts per day, averages per region. GROUP BY partitions rows into groups, and aggregate functions are applied within each group. The combination of GROUP BY and aggregates is what makes SQL a reporting language, not just a storage language.

The NULL problem. NULL represents an unknown or missing value. Aggregates must define how NULL is treated. SQL’s choice is that aggregates (except COUNT(*)) ignore NULLs, and this choice has consequences: AVG computes the average of the values that exist, not the average of all rows. A column with many NULLs will produce an AVG that reflects only the known values, which may or may not be what the analyst intends.

The correctness problem. Aggregates can silently return wrong answers. A SUM that includes duplicate rows from an unintended join, a COUNT that counts NULLs as values, an AVG that divides by the wrong denominator — these are common errors that produce plausible-looking numbers. Understanding the exact semantics of each aggregate, and how joins and NULLs affect them, is the difference between a query that reports correctly and one that misleads.

The performance problem. Aggregates require scanning rows, and on large tables this is expensive. Indexes can sometimes satisfy MIN and MAX without a scan, but COUNT, SUM, and AVG generally require reading the relevant rows. GROUP BY requires sorting or hashing, which adds cost. Understanding how aggregates are executed helps in designing indexes and in deciding when to pre-compute aggregates in summary tables.


a. COUNT: counting rows and non-NULL values

COUNT has two forms with different semantics. COUNT(*) counts all rows in the group, including rows with NULL values in any column. COUNT(column) counts rows where the specified column is not NULL.

-- Count all employees
SELECT COUNT(*) FROM employees;

-- Count employees with a manager assigned
SELECT COUNT(manager_id) FROM employees;

If the manager_id column has NULL for employees with no manager, COUNT(*) returns the total number of employees and COUNT(manager_id) returns the number who have a manager. The difference is the number of NULLs.

COUNT(DISTINCT column) counts distinct non-NULL values:

-- Count distinct departments that have at least one employee
SELECT COUNT(DISTINCT department_id) FROM employees;

COUNT never returns NULL. If no rows match, COUNT(*) returns 0 and COUNT(column) returns 0. This makes COUNT safe to use in contexts where a NULL result would be problematic.

b. SUM: adding numeric values

SUM adds up the values in a numeric column, ignoring NULLs.

-- Total salary expense
SELECT SUM(salary) FROM employees;

-- Total salary per department
SELECT department_id, SUM(salary) AS total_salary
FROM employees
GROUP BY department_id;

The critical behavior: if no rows match the WHERE clause, or if all values are NULL, SUM returns NULL, not zero. This surprises people who expect 0. Coalescing the result to zero requires COALESCE(SUM(column), 0).

-- Returns NULL if no rows match
SELECT SUM(salary) FROM employees WHERE department_id = 99;

-- Returns 0 instead
SELECT COALESCE(SUM(salary), 0) FROM employees WHERE department_id = 99;

SUM is only meaningful for numeric columns. Applying it to text or dates is either an error or produces unexpected results, depending on the database.

c. AVG: computing the mean

AVG computes the arithmetic mean of a numeric column, ignoring NULLs.

-- Average salary across all employees
SELECT AVG(salary) FROM employees;

-- Average salary per department
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id;

The NULL behavior is the source of most AVG errors. Because AVG ignores NULLs, the denominator is the count of non-NULL values, not the count of rows. If a department has 10 employees but 3 have NULL salary, the AVG is computed over 7 values, not 10.

-- These are different when salary has NULLs
SELECT AVG(salary) FROM employees;
SELECT SUM(salary) / COUNT(*) FROM employees;

The first divides by the count of non-NULL salaries; the second divides by the count of all rows. If the intent is to average over all rows, treating NULL as zero, the second form is correct. If the intent is to average the known values, the first form is correct. The choice depends on the question being asked.

AVG returns NULL if there are no non-NULL values. It returns a numeric type that may have more decimal places than the input column.

d. MIN and MAX: finding extremes

MIN returns the smallest value in a column; MAX returns the largest. Both ignore NULLs and work with numeric, string, and date columns.

-- Salary range
SELECT MIN(salary) AS lowest, MAX(salary) AS highest FROM employees;

-- Earliest and latest hire dates
SELECT MIN(hire_date) AS first_hire, MAX(hire_date) AS latest_hire
FROM employees;

-- Alphabetical extremes of names
SELECT MIN(last_name) AS first_alpha, MAX(last_name) AS last_alpha
FROM employees;

For dates, MIN is the earliest and MAX is the latest. For strings, MIN and MAX use the collation order of the database, which may be case-sensitive or case-insensitive depending on configuration. For numbers, the ordering is numeric.

MIN and MAX return NULL if no rows match or if all values are NULL. Like the other aggregates, they ignore NULL values when computing the extreme among non-NULL values.

A common pattern is to find the row that contains the extreme value, not just the value itself:

-- Employee with the highest salary
SELECT first_name, last_name, salary
FROM employees
WHERE salary = (SELECT MAX(salary) FROM employees);

This subquery form works but may return multiple rows if there are ties. A window function (ROW_NUMBER or RANK) provides more control when ties matter.

e. GROUP BY and aggregates

Aggregates become most useful with GROUP BY, which partitions rows into groups and applies the aggregate within each group.

SELECT department_id,
       COUNT(*) AS employee_count,
       SUM(salary) AS total_salary,
       AVG(salary) AS avg_salary,
       MIN(salary) AS min_salary,
       MAX(salary) AS max_salary
FROM employees
GROUP BY department_id;

Every column in the SELECT list that is not an aggregate must appear in the GROUP BY clause. This is a rule in standard SQL, enforced by most databases. The reason is that a non-aggregated column in the SELECT list has no single value per group unless it is part of the grouping key.

-- Error in standard SQL: first_name is not grouped or aggregated
SELECT department_id, first_name, COUNT(*)
FROM employees
GROUP BY department_id;

MySQL historically permitted this and returned an arbitrary value for first_name, a behavior that has caused countless bugs. Modern MySQL with ONLY_FULL_GROUP_BY enabled (the default since 5.7) enforces the standard rule.

f. Filtering groups with HAVING

WHERE filters rows before grouping. HAVING filters groups after aggregation. The distinction matters because aggregate results are not available to WHERE.

-- Departments with more than 5 employees
SELECT department_id, COUNT(*) AS employee_count
FROM employees
GROUP BY department_id
HAVING COUNT(*) > 5;

WHERE cannot reference an aggregate because WHERE is evaluated before groups exist. HAVING is evaluated after grouping and can reference aggregates. A query can have both:

-- Departments with more than 5 active employees, average salary over 60000
SELECT department_id,
       COUNT(*) AS active_count,
       AVG(salary) AS avg_salary
FROM employees
WHERE active = TRUE
GROUP BY department_id
HAVING COUNT(*) > 5 AND AVG(salary) > 60000;

The WHERE filters inactive employees before grouping; the HAVING filters groups after aggregation.

g. Aggregates and NULL: the complete picture

The NULL behavior of each aggregate is worth summarizing explicitly.

AggregateNULL behaviorEmpty result
COUNT(*)Counts all rows0
COUNT(col)Counts non-NULL values0
COUNT(DISTINCT col)Counts distinct non-NULL values0
SUMIgnores NULLsNULL
AVGIgnores NULLs; divides by non-NULL countNULL
MINIgnores NULLsNULL
MAXIgnores NULLsNULL

The practical implications: a SUM over a column with NULLs produces a sum of the known values, not an error. An AVG over a column with NULLs computes the mean of the known values. A COUNT(*) over an empty result set returns 0, but a SUM over an empty result set returns NULL. Coalescing SUM and AVG results to a default requires COALESCE.


Complete Example Session

-- ============================================
-- PART 1: CREATE A SAMPLE TABLE
-- ============================================
CREATE TABLE employees (
    employee_id   INTEGER PRIMARY KEY,
    first_name    VARCHAR(50),
    department_id INTEGER,
    salary        NUMERIC(10, 2),
    hire_date     DATE,
    commission    NUMERIC(10, 2)
);

INSERT INTO employees VALUES
(101, 'Alice',  1, 95000, '2020-03-15', NULL),
(102, 'Bob',    1, 78000, '2021-06-01', 5000),
(103, 'Carol',  2, 82000, '2019-11-20', NULL),
(104, 'David',  2, 65000, '2022-01-10', 3000),
(105, 'Eve',    3, 72000, '2020-08-05', NULL),
(106, 'Frank',  3, 68000, '2021-09-15', 2000),
(107, 'Grace',  1, 88000, '2022-04-01', NULL);
-- ============================================
-- PART 2: COUNT ROWS AND NON-NULLS
-- ============================================
SELECT COUNT(*)              AS total_rows,
       COUNT(commission)     AS with_commission,
       COUNT(*) - COUNT(commission) AS without_commission
FROM employees;
-- ============================================
-- PART 3: SUM WITH COALESCE
-- ============================================
-- SUM returns NULL if no rows match.

SELECT SUM(salary) AS total_salary FROM employees;

SELECT COALESCE(SUM(salary), 0) AS total_salary
FROM employees
WHERE department_id = 99;
-- ============================================
-- PART 4: AVG AND THE NULL DENOMINATOR
-- ============================================
-- AVG ignores NULLs; compare the two forms.

SELECT AVG(commission)                AS avg_of_non_null,
       SUM(commission) / COUNT(*)     AS avg_over_all_rows
FROM employees;
-- ============================================
-- PART 5: MIN AND MAX
-- ============================================
-- Works on numbers, dates, and strings.

SELECT MIN(salary)    AS lowest_salary,
       MAX(salary)    AS highest_salary,
       MIN(hire_date) AS first_hire,
       MAX(hire_date) AS latest_hire
FROM employees;
-- ============================================
-- PART 6: GROUP BY WITH ALL AGGREGATES
-- ============================================
SELECT department_id,
       COUNT(*)        AS employee_count,
       SUM(salary)     AS total_salary,
       AVG(salary)     AS avg_salary,
       MIN(salary)     AS min_salary,
       MAX(salary)     AS max_salary
FROM employees
GROUP BY department_id
ORDER BY department_id;
-- ============================================
-- PART 7: HAVING FILTERS GROUPS
-- ============================================
SELECT department_id, AVG(salary) AS avg_salary
FROM employees
GROUP BY department_id
HAVING AVG(salary) > 75000;
-- ============================================
-- PART 8: WHERE FILTERS ROWS BEFORE GROUPING
-- ============================================
SELECT department_id, COUNT(*) AS high_earners
FROM employees
WHERE salary > 70000
GROUP BY department_id
HAVING COUNT(*) >= 2;
-- ============================================
-- PART 9: COUNT DISTINCT
-- ============================================
SELECT COUNT(DISTINCT department_id) AS distinct_departments
FROM employees;
-- ============================================
-- PART 10: ROW WITH THE MAXIMUM VALUE
-- ============================================
-- Find the employee with the highest salary.

SELECT first_name, salary
FROM employees
WHERE salary = (SELECT MAX(salary) FROM employees);

These ten parts cover the five standard aggregates, their NULL behavior, the distinction between COUNT(*) and COUNT(column), the AVG denominator issue, GROUP BY with multiple aggregates, WHERE versus HAVING, COUNT DISTINCT, and finding the row containing the extreme value. Each pattern addresses a different analytical question.


Quick Reference

The Five Standard Aggregates

FunctionReturnsNULL BehaviorEmpty Result
COUNT(*)Number of rowsCounts all rows0
COUNT(col)Non-NULL countIgnores NULLs0
SUMTotalIgnores NULLsNULL
AVGMeanIgnores NULLsNULL
MINSmallestIgnores NULLsNULL
MAXLargestIgnores NULLsNULL

COUNT Variants

FormCounts
COUNT(*)All rows including NULLs
COUNT(col)Non-NULL values
COUNT(DISTINCT col)Distinct non-NULL values

WHERE vs HAVING

ClauseFiltersAggregate Available
WHERERows before groupingNo
HAVINGGroups after aggregationYes

Aggregate Use Cases

QuestionAggregate
How many rows?COUNT(*)
How many have a value?COUNT(col)
How many distinct values?COUNT(DISTINCT col)
What is the total?SUM
What is the average?AVG
What is the smallest/largest?MIN / MAX

Best Practices

✅ Do This:

SELECT COUNT(*) FROM employees;                        -- Count rows
SELECT COUNT(commission) FROM employees;              -- Count non-NULLs
SELECT COALESCE(SUM(salary), 0) FROM employees;       -- Default for empty
SELECT AVG(salary) FROM employees;                    -- Mean of non-NULLs
SELECT department_id, COUNT(*) FROM employees GROUP BY department_id;
SELECT department_id, AVG(salary) FROM employees
GROUP BY department_id HAVING AVG(salary) > 70000;

❌ Don’t Do This:

SELECT COUNT(commission) FROM employees;              -- ❌ If you meant row count
SELECT SUM(salary) FROM employees WHERE false;        -- ❌ Returns NULL, not 0
SELECT department_id, first_name, COUNT(*) FROM employees
GROUP BY department_id;                               -- ❌ first_name not grouped
SELECT AVG(commission) FROM employees;                -- ❌ Divides by non-NULL count only

Common Pitfalls

PitfallWhy It HappensFix
COUNT returns unexpected numberCOUNT(col) skips NULLsUse COUNT(*) for row count
SUM returns NULLNo rows matchedWrap in COALESCE
AVG denominator wrongNULLs excludedUse SUM/COUNT(*) for all-row average
Non-grouped column errorColumn not in GROUP BYAdd to GROUP BY or aggregate
HAVING used for row filterAggregate not neededUse WHERE for row-level conditions
JOIN inflates aggregateDuplicate rows from joinUse DISTINCT or pre-aggregate
MIN/MAX string ordering surpriseCollation affects orderingCheck collation settings
AVG precision unexpectedInteger division or typeCast to decimal if needed

Real-World Examples

1. Total Revenue

SELECT SUM(price * quantity) AS total_revenue FROM order_items;

2. Employee Count per Department

SELECT department_id, COUNT(*) AS headcount
FROM employees GROUP BY department_id;

3. Average Order Value

SELECT AVG(total) AS avg_order_value FROM orders;

4. Highest and Lowest Price

SELECT MIN(price) AS cheapest, MAX(price) AS priciest FROM products;

5. First and Last Order Date

SELECT MIN(order_date) AS first_order, MAX(order_date) AS last_order FROM orders;

6. Departments with High Average Salary

SELECT department_id, AVG(salary) AS avg_salary
FROM employees GROUP BY department_id HAVING AVG(salary) > 80000;

7. Count of Distinct Customers

SELECT COUNT(DISTINCT customer_id) AS unique_customers FROM orders;

8. Employee with Highest Salary

SELECT first_name, last_name, salary
FROM employees WHERE salary = (SELECT MAX(salary) FROM employees);

9. Daily Order Count

SELECT DATE(order_date) AS day, COUNT(*) AS orders
FROM orders GROUP BY DATE(order_date) ORDER BY day;

10. Conditional Aggregate

SELECT COUNT(*) AS total,
       SUM(CASE WHEN status = 'active' THEN 1 ELSE 0 END) AS active_count
FROM users;

Visual

Aggregate Function Behavior

┌──────────────────────────────────────────────────────────────┐
│  HOW AGGREGATES REDUCE ROWS                                  │
│                                                              │
│  Input rows (salary column):                                 │
│  ┌─────────┐                                                 │
│  │ 95000   │                                                 │
│  │ 78000   │                                                 │
│  │ 82000   │                                                 │
│  │ 65000   │                                                 │
│  │ 72000   │                                                 │
│  └─────────┘                                                 │
│       │                                                      │
│       ├── COUNT(*)         → 5                               │
│       ├── SUM(salary)      → 392000                          │
│       ├── AVG(salary)      → 78400                           │
│       ├── MIN(salary)      → 65000                           │
│       └── MAX(salary)      → 95000                           │
│                                                              │
│  Many rows in, one value out per aggregate.                  │
└──────────────────────────────────────────────────────────────┘

COUNT(*) vs COUNT(column)

┌──────────────────────────────────────────────────────────────┐
│  COUNT(*) COUNTS ROWS; COUNT(col) SKIPS NULLs                │
│                                                              │
│  ┌────┬────────┬────────────┐                                │
│  │ id │ name   │ commission │                                │
│  ├────┼────────┼────────────┤                                │
│  │101 │ Alice  │ NULL       │                                │
│  │102 │ Bob    │ 5000       │                                │
│  │103 │ Carol  │ NULL       │                                │
│  │104 │ David  │ 3000       │                                │
│  └────┴────────┴────────────┘                                │
│                                                              │
│  COUNT(*)                 → 4   (all rows)                   │
│  COUNT(commission)        → 2   (non-NULL only)              │
│  COUNT(DISTINCT commission) → 2 (5000 and 3000)              │
│                                                              │
│  The difference between COUNT(*) and COUNT(col) is the       │
│  number of NULL values in that column.                       │
└──────────────────────────────────────────────────────────────┘

AVG and the NULL Denominator

┌──────────────────────────────────────────────────────────────┐
│  AVG IGNORES NULLs: DIFFERENT ANSWERS                        │
│                                                              │
│  commission values: NULL, 5000, NULL, 3000                   │
│                                                              │
│  AVG(commission):                                            │
│  └── (5000 + 3000) / 2 = 4000                                │
│      Divides by non-NULL count                               │
│                                                              │
│  SUM(commission) / COUNT(*):                                 │
│  └── (5000 + 3000) / 4 = 2000                                │
│      Divides by all rows                                     │
│                                                              │
│  Neither is wrong; the question determines the right form.   │
│  "Average commission among those who earn it" → 4000         │
│  "Average commission across all employees"   → 2000          │
└──────────────────────────────────────────────────────────────┘

WHERE vs HAVING

┌──────────────────────────────────────────────────────────────┐
│  WHERE FILTERS ROWS; HAVING FILTERS GROUPS                   │
│                                                              │
│  All rows                                                    │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  WHERE salary > 50000                                  │  │
│  │  (filters individual rows before grouping)             │  │
│  └────────────────────────────────────────────────────────┘  │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  GROUP BY department_id                                │  │
│  └────────────────────────────────────────────────────────┘  │
│       │                                                      │
│       ▼                                                      │
│  ┌────────────────────────────────────────────────────────┐  │
│  │  HAVING COUNT(*) > 5                                   │  │
│  │  (filters groups after aggregation)                    │  │
│  └────────────────────────────────────────────────────────┘  │
│       │                                                      │
│       ▼                                                      │
│  Final result                                                │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Five standard aggregatesCOUNT, SUM, AVG, MIN, MAX
COUNT(*)Counts all rows including NULLs
COUNT(col)Counts non-NULL values
COUNT(DISTINCT col)Counts distinct non-NULL values
SUM empty resultNULL (use COALESCE for 0)
AVG denominatorNon-NULL count only
MIN/MAXWork on numbers, dates, strings
GROUP BYPartitions rows for per-group aggregation
WHEREFilters rows before grouping
HAVINGFilters groups after aggregation
Non-aggregated columnsMust appear in GROUP BY

Key takeaways:

  • COUNT(*) and COUNT(column) differ. COUNT(*) counts rows; COUNT(column) counts non-NULL values in that column. The difference is the number of NULLs.
  • All aggregates except COUNT(*) ignore NULLs. SUM, AVG, MIN, and MAX operate on the non-NULL values only. This affects the denominator in AVG and the result when all values are NULL.
  • SUM and AVG return NULL for no rows. COUNT returns 0. Use COALESCE to default SUM and AVG to zero when an empty result should display as 0.
  • AVG divides by the non-NULL count. This is the average of the known values. For the average across all rows, use SUM(column) / COUNT(*).
  • GROUP BY partitions rows. Non-aggregated columns in the SELECT list must appear in the GROUP BY clause. Most databases enforce this rule.
  • WHERE filters rows before grouping; HAVING filters groups. Use WHERE for row-level conditions and HAVING for aggregate conditions.
  • COUNT(DISTINCT) counts unique values. It is useful for answering “how many different customers placed orders” rather than “how many orders.”
  • Aggregates can be inflated by joins. A join that produces duplicate rows will inflate SUM and COUNT. Verify the join or pre-aggregate before joining.

Remember: Aggregate functions are the mechanism for summarizing data in SQL. COUNT, SUM, AVG, MIN, and MAX reduce many rows to one value per group, and they are almost always used with GROUP BY for reporting. The semantics that matter most are NULL handling and the denominator in AVG. COUNT(*) counts rows; COUNT(column) counts non-NULL values; all other aggregates ignore NULLs. SUM and AVG return NULL when no rows match, not zero. AVG divides by the count of non-NULL values, which is the average of the known values, not the average across all rows. WHERE filters rows before grouping; HAVING filters groups after aggregation. Understanding these rules prevents the most common errors in analytical queries and produces results that can be trusted.



Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!