Lesson 5 of 30
Aggregation, GROUP BY, and HAVING
An aggregate function turns many rows into one value. GROUP BY splits the rows into groups first, so you get one value per group.
| Function | Result |
|---|---|
count(*) |
number of rows |
count(col) |
number of non-NULL values |
count(DISTINCT col) |
number of distinct non-NULL values |
sum(col), avg(col) |
total and mean, ignoring NULL |
min(col), max(col) |
smallest and largest |
Example 1: aggregate the whole table
Without GROUP BY, the whole table is one group.
SELECT count(*) AS employees,
sum(salary) AS payroll,
round(avg(salary), 2) AS average,
min(salary) AS lowest,
max(salary) AS highest
FROM employees; employees | payroll | average | lowest | highest
-----------+---------+-----------+--------+---------
12 | 1540000 | 128333.33 | 70000 | 250000
(1 row)Example 2: GROUP BY
SELECT dept_id, count(*) AS headcount, sum(salary) AS payroll
FROM employees
GROUP BY dept_id
ORDER BY dept_id; dept_id | headcount | payroll
---------+-----------+---------
1 | 5 | 840000
2 | 3 | 335000
3 | 2 | 200000
4 | 1 | 95000
| 1 | 70000
(5 rows)Example 3: every non-aggregated column must be grouped
SELECT dept_id, name, count(*)
FROM employees
GROUP BY dept_id;ERROR: column "employees.name" must appear in the GROUP BY clause or be used in an aggregate function
LINE 1: SELECT dept_id, name, count(*)
^The database cannot pick one name for a group of five. Either add name to GROUP BY, or aggregate it.
SELECT dept_id, string_agg(name, ', ' ORDER BY name) AS people
FROM employees
GROUP BY dept_id
ORDER BY dept_id; dept_id | people
---------+---------------------------------
1 | Arjun, Kiran, Meera, Neha, Ravi
2 | Divya, Pooja, Sameer
3 | Anita, Vikram
4 | Rohan
| Tara
(5 rows)Example 4: group by several columns
SELECT customer_id, status, count(*) AS orders
FROM orders
GROUP BY customer_id, status
ORDER BY customer_id, status; customer_id | status | orders
-------------+-----------+--------
1 | delivered | 2
1 | shipped | 1
2 | cancelled | 1
2 | delivered | 1
3 | delivered | 2
4 | placed | 1
5 | placed | 1
(7 rows)Example 5: WHERE filters rows, HAVING filters groups
WHERE runs before grouping, so it cannot see aggregates. HAVING runs after.
SELECT dept_id, count(*) AS headcount
FROM employees
WHERE salary > 80000
GROUP BY dept_id
HAVING count(*) >= 2
ORDER BY dept_id; dept_id | headcount
---------+-----------
1 | 5
2 | 3
(2 rows)SELECT dept_id, count(*)
FROM employees
WHERE count(*) >= 2
GROUP BY dept_id;ERROR: aggregate functions are not allowed in WHERE
LINE 3: WHERE count(*) >= 2
^Put conditions on plain columns in WHERE. It removes rows earlier, so less data is grouped.
Example 6: count(*) vs count(column) vs count(DISTINCT)
SELECT count(*) AS rows,
count(dept_id) AS non_null_dept,
count(DISTINCT dept_id) AS distinct_depts
FROM employees; rows | non_null_dept | distinct_depts
------+---------------+----------------
12 | 11 | 4
(1 row)Example 7: conditional aggregation with FILTER
FILTER (PostgreSQL, SQLite) counts only rows that match. Other databases use sum(CASE WHEN ... THEN 1 ELSE 0 END).
SELECT customer_id,
count(*) AS total,
count(*) FILTER (WHERE status = 'delivered') AS delivered,
count(*) FILTER (WHERE status = 'cancelled') AS cancelled
FROM orders
GROUP BY customer_id
ORDER BY customer_id; customer_id | total | delivered | cancelled
-------------+-------+-----------+-----------
1 | 3 | 2 | 0
2 | 2 | 1 | 1
3 | 2 | 2 | 0
4 | 1 | 0 | 0
5 | 1 | 0 | 0
(5 rows)SELECT customer_id,
sum(CASE WHEN status = 'delivered' THEN 1 ELSE 0 END) AS delivered
FROM orders
GROUP BY customer_id
ORDER BY customer_id; customer_id | delivered
-------------+-----------
1 | 2
2 | 1
3 | 2
4 | 0
5 | 0
(5 rows)Example 8: pivot rows into columns
Conditional aggregation is the standard way to pivot.
SELECT d.name AS department,
count(*) FILTER (WHERE e.salary < 100000) AS under_100k,
count(*) FILTER (WHERE e.salary >= 100000) AS over_100k
FROM employees e
JOIN departments d ON d.id = e.dept_id
GROUP BY d.name
ORDER BY d.name; department | under_100k | over_100k
-------------+------------+-----------
Engineering | 0 | 5
HR | 1 | 0
Marketing | 1 | 1
Sales | 2 | 1
(4 rows)Example 9: aggregate over a join
Total value of each order: multiply price by quantity, then sum per order.
SELECT o.id AS order_id, sum(p.price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY o.id
ORDER BY o.id; order_id | total
----------+--------
1 | 950.00
2 | 50.00
3 | 460.00
4 | 75.00
5 | 900.00
6 | 75.00
7 | 200.00
8 | 170.00
9 | 125.00
(9 rows)Example 10: revenue per category, largest first
SELECT p.category, sum(p.price * oi.quantity) AS revenue
FROM order_items oi
JOIN products p ON p.id = oi.product_id
JOIN orders o ON o.id = oi.order_id
WHERE o.status <> 'cancelled'
GROUP BY p.category
ORDER BY revenue DESC; category | revenue
-------------+---------
Computers | 1800.00
Displays | 600.00
Accessories | 530.00
(3 rows)Example 11: ROLLUP adds subtotals
ROLLUP adds a subtotal row per group and a grand total. NULL in a grouping column marks a subtotal row.
SELECT status, customer_id, count(*) AS orders
FROM orders
WHERE customer_id IN (1, 2)
GROUP BY ROLLUP (status, customer_id)
ORDER BY status, customer_id; status | customer_id | orders
-----------+-------------+--------
cancelled | 2 | 1
cancelled | | 1
delivered | 1 | 2
delivered | 2 | 1
delivered | | 3
shipped | 1 | 1
shipped | | 1
| | 5
(8 rows)Example 12: a join before an aggregate can inflate the result
Joining orders to order_items repeats each order once per item. Counting orders after the join over-counts.
SELECT count(*) AS wrong, count(DISTINCT o.id) AS right
FROM orders o
JOIN order_items oi ON oi.order_id = o.id; wrong | right
-------+-------
13 | 9
(1 row)Summary
WHEREfilters rows,HAVINGfilters groups.- Every selected column is either in
GROUP BYor inside an aggregate. count(*)counts rows,count(col)skipsNULL.- After a join, check whether rows were repeated before you
countorsum.
