Aggregate Functions & Grouping
So far every query has returned individual rows — one row of output per row of data. Aggregate functions are different: they collapse many rows into a single summary value. This is how you answer questions like "What is the average salary?", "How many employees are in each department?", or "Which month had the highest sales?"
What Are Aggregate Functions?
Think of aggregate functions like a spreadsheet's SUM or AVERAGE formula applied to a column. You give the function a set of rows, and it returns a single number.
We will use this expanded employees table:
| emp_id | name | dept | salary | hire_date | bonus |
|---|---|---|---|---|---|
| 1 | Alice | HR | 60000 | 2020-01-15 | 3000 |
| 2 | Bob | IT | 85000 | 2019-03-10 | NULL |
| 3 | Carol | IT | 92000 | 2018-07-22 | 5000 |
| 4 | David | HR | 55000 | 2021-09-01 | NULL |
| 5 | Eve | Finance | 78000 | 2020-11-30 | 4000 |
| 6 | Frank | IT | 95000 | 2017-05-14 | 6000 |
Basic Aggregates Without Grouping
When you use an aggregate function without GROUP BY, it operates on the entire table and returns a single row:
SELECT
COUNT(*) AS total_employees, -- count every row
COUNT(bonus) AS employees_with_bonus, -- count non-NULL bonuses
SUM(salary) AS total_payroll,
ROUND(AVG(salary), 0) AS avg_salary,
MIN(salary) AS lowest_salary,
MAX(salary) AS highest_salary
FROM employees;
Result:
| total_employees | employees_with_bonus | total_payroll | avg_salary | lowest_salary | highest_salary |
|---|---|---|---|---|---|
| 6 | 4 | 465000 | 77500 | 55000 | 95000 |
Notice: COUNT(bonus) returns 4, not 6, because Bob and David have NULL bonuses. Aggregate functions (except COUNT(*)) ignore NULL values.
COUNT — Counting Rows
COUNT has three important variants, each with a different meaning:
-- COUNT(*): count every row, including rows with NULLs
SELECT COUNT(*) FROM employees;
-- Result: 6
-- COUNT(column): count rows where that column is NOT NULL
SELECT COUNT(bonus) FROM employees;
-- Result: 4 (Bob and David have NULL, so they are not counted)
-- COUNT(DISTINCT column): count unique non-NULL values
SELECT COUNT(DISTINCT dept) FROM employees;
-- Result: 3 (HR, IT, Finance — three unique departments)
-- Combining them in one query
SELECT
COUNT(*) AS total_rows,
COUNT(bonus) AS rows_with_bonus,
COUNT(DISTINCT dept) AS unique_depts
FROM employees;
Result:
| total_rows | rows_with_bonus | unique_depts |
|---|---|---|
| 6 | 4 | 3 |
COUNT(*) when you want to know how many rows exist (the most common use). Use COUNT(column) when you want to know how many rows have a value (non-NULL) in a specific column.
SUM, AVG, MIN, MAX and NULL
All of these functions automatically skip NULL values. This is usually the right behaviour, but be aware of it:
-- SUM ignores NULLs — adds only the 4 non-NULL bonuses
SELECT SUM(bonus) AS total_bonuses FROM employees;
-- Result: 3000 + 5000 + 4000 + 6000 = 18000
-- (NULL values from Bob and David are ignored)
-- AVG ignores NULLs — averages only the 4 non-NULL bonuses
SELECT AVG(bonus) AS avg_bonus FROM employees;
-- Result: 18000 / 4 = 4500 (NOT 18000 / 6 = 3000)
-- If you WANT to include NULLs as zero:
SELECT AVG(COALESCE(bonus, 0)) AS avg_bonus_incl_no_bonus FROM employees;
-- Result: 18000 / 6 = 3000
-- MIN and MAX on dates — returns earliest and latest
SELECT MIN(hire_date) AS first_hired,
MAX(hire_date) AS most_recently_hired
FROM employees;
-- Result: 2017-05-14 (Frank) and 2021-09-01 (David)
-- MIN and MAX on strings — returns alphabetically first and last
SELECT MIN(name) AS first_alphabetically,
MAX(name) AS last_alphabetically
FROM employees;
-- Result: Alice and Frank
AVG when some values are NULL. The average is computed over only the non-NULL values. If you have 10 rows and 4 are NULL, AVG divides by 6, not 10. Decide whether that is the correct behaviour for your use case.
GROUP BY — Summarising by Category
GROUP BY is what makes aggregate functions truly powerful. Instead of summarising the whole table, it groups rows by a column's value and applies the aggregate function to each group separately.
Mental model: Imagine sorting the employees table into three stacks of paper — one stack for HR, one for IT, one for Finance. Then you run COUNT or SUM on each stack independently.
SELECT dept,
COUNT(*) AS headcount,
SUM(salary) AS total_payroll,
ROUND(AVG(salary), 0) AS avg_salary,
MIN(salary) AS lowest_salary,
MAX(salary) AS highest_salary
FROM employees
GROUP BY dept
ORDER BY avg_salary DESC;
Result:
| dept | headcount | total_payroll | avg_salary | lowest_salary | highest_salary |
|---|---|---|---|---|---|
| IT | 3 | 272000 | 90667 | 85000 | 95000 |
| Finance | 1 | 78000 | 78000 | 78000 | 78000 |
| HR | 2 | 115000 | 57500 | 55000 | 60000 |
The database:
- Scanned all 6 rows
- Grouped them into 3 groups by
dept - Applied each aggregate function to each group
- Returned 3 summary rows — one per group
The Golden Rule of GROUP BY
Every column in your SELECT list that is NOT an aggregate function must appear in your GROUP BY clause.
-- WRONG: name is in SELECT but not in GROUP BY
SELECT dept, name, COUNT(*) -- ERROR: name must be in GROUP BY
FROM employees
GROUP BY dept;
-- CORRECT: only dept is non-aggregate, so only dept is in GROUP BY
SELECT dept, COUNT(*)
FROM employees
GROUP BY dept;
-- CORRECT: both non-aggregate columns are in GROUP BY
SELECT dept, name, salary -- no aggregates here
FROM employees
GROUP BY dept, name, salary; -- all three must be listed
The reason: each output row from GROUP BY represents a whole group of rows. A group can have many different name values — the database cannot pick just one without you telling it how.
GROUP BY with Multiple Columns
You can group by more than one column to create finer-grained summaries:
-- Group by department AND the year hired
SELECT dept,
EXTRACT(YEAR FROM hire_date) AS hire_year,
COUNT(*) AS headcount,
AVG(salary) AS avg_salary
FROM employees
GROUP BY dept,
EXTRACT(YEAR FROM hire_date)
ORDER BY dept, hire_year;
This gives you the number of employees hired in each combination of department and year.
HAVING — Filtering Groups
After GROUP BY creates groups, HAVING filters which groups appear in the result. It is like WHERE, but it runs after grouping and can use aggregate functions.
-- Only show departments with more than 1 employee
SELECT dept, COUNT(*) AS headcount
FROM employees
GROUP BY dept
HAVING COUNT(*) > 1;
Result:
| dept | headcount |
|---|---|
| IT | 3 |
| HR | 2 |
Finance has only 1 employee, so it is excluded.
-- Departments where the average salary exceeds 70000
SELECT dept,
ROUND(AVG(salary), 0) AS avg_sal
FROM employees
GROUP BY dept
HAVING AVG(salary) > 70000;
Result:
| dept | avg_sal |
|---|---|
| IT | 90667 |
| Finance | 78000 |
WHERE vs HAVING — The Key Difference
| WHERE | HAVING | |
|---|---|---|
| When it runs | Before GROUP BY — filters individual rows | After GROUP BY — filters groups |
| Can use aggregates? | No — aggregates do not exist yet | Yes — the whole point |
| Filters | Individual rows | Groups (summaries) |
-- Use WHERE for row-level filters, HAVING for group-level filters
SELECT dept,
COUNT(*) AS headcount,
AVG(salary) AS avg_sal
FROM employees
WHERE hire_date >= '2018-01-01' -- filter rows BEFORE grouping
GROUP BY dept
HAVING COUNT(*) > 1 -- filter groups AFTER grouping
ORDER BY avg_sal DESC;
The WHERE runs first and removes employees hired before 2018. Then the remaining rows are grouped, and HAVING removes groups with only 1 employee.
WHERE AVG(salary) > 70000 will give an error. Aggregate functions in filter conditions belong in HAVING.
HAVING without GROUP BY
HAVING can technically be used without GROUP BY — in that case the entire table is treated as one group:
-- Returns nothing if average salary is not above 80000, otherwise all employees
SELECT name, salary
FROM employees
HAVING AVG(salary) > 70000;
-- Result: all 6 employees (because 77500 > 70000)
This is rarely useful in practice. The common pattern is GROUP BY + HAVING.
The Complete Query Execution Order
This is one of the most important things to memorise in SQL. The clauses are written in one order but processed in a different order:
SELECT dept,
COUNT(*) AS headcount, -- 5: calculate output columns
AVG(salary) AS avg_sal
FROM employees -- 1: get source data
WHERE hire_date > '2017-01-01' -- 2: filter individual rows
GROUP BY dept -- 3: group the remaining rows
HAVING COUNT(*) > 1 -- 4: filter groups
ORDER BY avg_sal DESC -- 6: sort the output
LIMIT 10; -- 7: limit the rows returned
Why this matters:
WHEREcannot reference SELECT aliases (aliases don't exist yet at step 2)HAVINGcan use aggregate functions (step 4 has access to step 3's groups)ORDER BYcan reference SELECT aliases (step 6 sees step 5's output)
-- This fails — ORDER BY alias is fine but WHERE alias is not
SELECT salary * 12 AS annual_salary
FROM employees
WHERE annual_salary > 800000; -- ERROR: column "annual_salary" unknown at WHERE step
-- Fix: repeat the expression in WHERE
SELECT salary * 12 AS annual_salary
FROM employees
WHERE salary * 12 > 800000; -- correct
ROLLUP — Subtotals
ROLLUP extends GROUP BY to also include subtotal rows and a grand total row.
SELECT dept,
EXTRACT(YEAR FROM hire_date) AS year,
SUM(salary) AS total_salary
FROM employees
GROUP BY ROLLUP (dept, year)
ORDER BY dept NULLS LAST, year NULLS LAST;
Result (approximate):
| dept | year | total_salary |
|---|---|---|
| Finance | 2020 | 78000 |
| Finance | NULL | 78000 |
| HR | 2020 | 60000 |
| HR | 2021 | 55000 |
| HR | NULL | 115000 |
| IT | 2017 | 95000 |
| IT | 2018 | 92000 |
| IT | 2019 | 85000 |
| IT | NULL | 272000 |
| NULL | NULL | 465000 |
ROLLUP produces subtotals by "rolling up" the rightmost GROUP BY column first, then the next, all the way to the full grand total.
GROUPING(dept) or COALESCE(dept, 'ALL DEPTS') to display those rows more meaningfully.
-- Distinguish subtotal rows from real NULLs
SELECT COALESCE(dept, 'ALL DEPARTMENTS') AS dept,
COALESCE(CAST(EXTRACT(YEAR FROM hire_date) AS VARCHAR), 'ALL YEARS') AS year,
SUM(salary) AS total_salary
FROM employees
GROUP BY ROLLUP (dept, EXTRACT(YEAR FROM hire_date));
CUBE — All Combinations
CUBE goes further than ROLLUP — it generates subtotals for every possible combination of the grouping columns.
SELECT dept,
EXTRACT(YEAR FROM hire_date) AS year,
SUM(salary) AS total_salary
FROM employees
GROUP BY CUBE (dept, year);
With two grouping columns CUBE produces:
- All individual (dept, year) combinations
- Subtotals for just dept (year = NULL)
- Subtotals for just year (dept = NULL)
- The grand total (both NULL)
This is useful for building multi-dimensional summary reports (like pivot tables in Excel).
GROUPING SETS — Custom Combinations
GROUPING SETS lets you specify exactly which groupings you want, without generating all combinations:
-- I want: per-department totals, per-year totals, and a grand total
-- But NOT per (department, year) combinations
SELECT dept,
EXTRACT(YEAR FROM hire_date) AS year,
SUM(salary) AS total_salary
FROM employees
GROUP BY GROUPING SETS (
(dept), -- subtotal per department
(EXTRACT(YEAR FROM hire_date)), -- subtotal per year
() -- grand total (empty = all rows)
);
This gives you exactly the three types of aggregation you want, without the cartesian combinations that CUBE would produce.
Practical Real-World Examples
Sales by Region and Month
-- Monthly sales summary per region
SELECT region,
TO_CHAR(order_date, 'YYYY-MM') AS month,
COUNT(*) AS num_orders,
SUM(order_total) AS revenue,
ROUND(AVG(order_total), 2) AS avg_order_value,
MAX(order_total) AS largest_order
FROM orders
WHERE order_date >= '2024-01-01'
GROUP BY region, TO_CHAR(order_date, 'YYYY-MM')
HAVING COUNT(*) >= 10 -- only months with at least 10 orders
ORDER BY month, region;
Department Headcount and Pay Analysis
-- Salary analysis with salary band distribution
SELECT dept,
COUNT(*) AS headcount,
SUM(salary) AS payroll,
ROUND(AVG(salary), 0) AS avg_salary,
MAX(salary) - MIN(salary) AS salary_spread,
COUNT(CASE WHEN salary >= 80000 THEN 1 END) AS senior_count,
COUNT(CASE WHEN salary < 80000 THEN 1 END) AS junior_count
FROM employees
GROUP BY dept
ORDER BY payroll DESC;
Finding Duplicates
-- Find duplicate email addresses (more than one person with same email)
SELECT email,
COUNT(*) AS occurrences
FROM users
GROUP BY email
HAVING COUNT(*) > 1
ORDER BY occurrences DESC;
Common Mistakes with Aggregates
Mistake 1: Non-Grouped Column in SELECT
-- WRONG: name is not in GROUP BY and is not an aggregate
SELECT dept, name, COUNT(*)
FROM employees
GROUP BY dept;
-- ERROR: column "name" must appear in GROUP BY or be used in aggregate
-- CORRECT options:
SELECT dept, COUNT(*) FROM employees GROUP BY dept;
-- or, if you want name too:
SELECT dept, name, COUNT(*) FROM employees GROUP BY dept, name;
Mistake 2: Using SELECT Alias in HAVING
-- WRONG: HAVING runs before SELECT, so the alias does not exist yet
SELECT dept, AVG(salary) AS avg_sal
FROM employees
GROUP BY dept
HAVING avg_sal > 70000; -- ERROR in most databases (works in MySQL but not standard)
-- CORRECT: repeat the expression in HAVING
SELECT dept, AVG(salary) AS avg_sal
FROM employees
GROUP BY dept
HAVING AVG(salary) > 70000;
Mistake 3: Aggregate in WHERE Instead of HAVING
-- WRONG: WHERE cannot use aggregate functions
SELECT dept
FROM employees
WHERE AVG(salary) > 70000 -- ERROR
GROUP BY dept;
-- CORRECT: aggregates filter with HAVING
SELECT dept
FROM employees
GROUP BY dept
HAVING AVG(salary) > 70000;
1. Is every non-aggregate SELECT column also in GROUP BY?
2. Are row-level filters in WHERE and group-level filters in HAVING?
3. Are SELECT aliases not used in HAVING (use the full expression there)?
Common Errors
| Error | Cause | Fix |
|---|---|---|
| ORA-00937 | Not a single-group group function — mixing aggregate and non-aggregate columns without GROUP BY | Add all non-aggregate SELECT columns to the GROUP BY clause |
| ORA-00979 | Not a GROUP BY expression — a SELECT column is neither aggregated nor in GROUP BY | Either aggregate the column or add it to GROUP BY |
| ORA-00934 | Group function not allowed here — aggregate used inside a WHERE clause | Move aggregate conditions to HAVING; WHERE filters rows before grouping |
| ORA-01722 | Invalid number — non-numeric value passed to SUM or AVG | Ensure the column or expression is numeric; use TO_NUMBER if needed |
| ORA-30487 | ORDER BY not allowed in window function — ORDER BY used incorrectly inside an aggregate | Remove ORDER BY from scalar aggregates; use window functions for ordered aggregation |
| ORA-00932 | Inconsistent datatypes in aggregate — mixing types like SUM(salary || 'x') |
Ensure aggregate functions receive the correct datatype |
Interview Corner
COUNT(*), COUNT(column), and COUNT(DISTINCT column)?▶ Show answer
| Form | Counts | NULL handling |
|---|---|---|
COUNT(*) |
All rows in the group (including NULLs) | NULLs are included |
COUNT(column) |
Rows where column is NOT NULL |
NULLs excluded |
COUNT(DISTINCT column) |
Distinct non-NULL values of column |
NULLs excluded |
SELECT COUNT(*) AS total_rows, -- e.g. 107
COUNT(commission_pct) AS has_commission, -- e.g. 35 (non-NULL only)
COUNT(DISTINCT department_id) AS num_depts -- e.g. 11
FROM employees;
Use COUNT(*) to count rows. Use COUNT(column) to count populated values. Use COUNT(DISTINCT column) for cardinality.
SELECT inside the HAVING clause?▶ Show answer
SQL has a defined logical processing order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY. The SELECT list (where aliases are defined) is evaluated after HAVING, so the alias does not yet exist when HAVING is evaluated.
-- Wrong — alias 'avg_sal' not yet defined when HAVING runs
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY department_id
HAVING avg_sal > 8000; -- ORA-00904 or similar
-- Correct — repeat the expression in HAVING
SELECT department_id, AVG(salary) AS avg_sal
FROM employees
GROUP BY department_id
HAVING AVG(salary) > 8000;
Oracle (unlike some databases) does not support alias references in HAVING.
Related Topics
- Filtering & Conditions — WHERE clause to filter individual rows before grouping
- Joins — aggregate across multiple tables using GROUP BY after joining
- Window Functions — running totals and rankings without collapsing rows
- Subqueries — filter groups using subqueries in HAVING