05 · Aggregate Functions & GROUP BY¶
🎥 Video walkthrough¶
Aggregate functions collapse many rows into a single summary value —
counting, summing, averaging. Combined with GROUP BY, they let you compute
those summaries per category instead of over the whole table.
We'll extend the books table from earlier modules with a couple more rows so
grouping is more interesting:
CREATE TABLE books (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
price REAL,
published_year INTEGER,
genre TEXT
);
INSERT INTO books (title, author, price, published_year, genre) VALUES
('Dune', 'Frank Herbert', 9.99, 1965, 'Sci-Fi'),
('Foundation', 'Isaac Asimov', 8.50, 1951, 'Sci-Fi'),
('Neuromancer', 'William Gibson', 7.25, 1984, 'Cyberpunk'),
('The Left Hand of Darkness', 'Ursula K. Le Guin', 8.99, 1969, 'Sci-Fi'),
('Snow Crash', 'Neal Stephenson', 10.50, 1992, 'Cyberpunk'),
('The Hobbit', 'J.R.R. Tolkien', 6.99, 1937, 'Fantasy'),
('The Fellowship of the Ring', 'J.R.R. Tolkien', 9.50, 1954, 'Fantasy');
The five core aggregate functions¶
SELECT COUNT(*) AS total_books FROM books;
SELECT SUM(price) AS total_price FROM books;
SELECT AVG(price) AS average_price FROM books;
SELECT MIN(price) AS cheapest FROM books;
SELECT MAX(price) AS priciest FROM books;
COUNT(*) counts rows regardless of NULLs. COUNT(column) counts only rows
where that column is not NULL — an important distinction covered more in
Module 7:
GROUP BY — one summary per group¶
GROUP BY splits rows into buckets by the value(s) of one or more columns,
then computes the aggregate separately for each bucket:
SELECT genre, AVG(price) AS avg_price, MIN(price) AS min_price, MAX(price) AS max_price
FROM books
GROUP BY genre;
genre avg_price min_price max_price
--------- --------- --------- ---------
Cyberpunk 8.875 7.25 10.5
Fantasy 8.245 6.99 9.5
Sci-Fi 9.16 8.5 9.99
Rule: every column in SELECT that isn't wrapped in an aggregate function
must appear in GROUP BY. SELECT genre, title, AVG(price) ... GROUP BY
genre is invalid — SQL wouldn't know which title to show for a genre with
multiple books. SQLite is lenient about enforcing this (it'll pick an
arbitrary row), but Postgres and MySQL (in strict mode) reject it outright —
treat it as an error everywhere.
Grouping by multiple columns¶
Each unique combination of genre and published_year becomes its own
group.
HAVING — filtering groups, not rows¶
WHERE filters individual rows before grouping happens. HAVING filters
groups after aggregation — you need it because aggregate functions like
COUNT(*) don't exist yet at the point WHERE is evaluated.
-- WRONG: WHERE can't reference an aggregate
-- SELECT genre, COUNT(*) FROM books WHERE COUNT(*) > 2 GROUP BY genre;
-- RIGHT: HAVING filters after grouping
SELECT genre, COUNT(*) AS num_books
FROM books
GROUP BY genre
HAVING COUNT(*) >= 2;
You can combine WHERE and HAVING in one query — WHERE trims rows first,
then grouping happens on what's left, then HAVING trims groups:
SELECT genre, AVG(price) AS avg_price
FROM books
WHERE published_year >= 1950
GROUP BY genre
HAVING AVG(price) > 8.00;
Clause order (and evaluation order)¶
The clauses must be written in this order:
But SQL evaluates them in a different logical order: FROM → WHERE →
GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. This is why WHERE
can't see aggregate results (they're computed after it runs) and why
ORDER BY can reference a SELECT alias (it runs after SELECT).
A full example¶
SELECT genre, COUNT(*) AS num_books, ROUND(AVG(price), 2) AS avg_price
FROM books
WHERE published_year >= 1950
GROUP BY genre
HAVING COUNT(*) > 1
ORDER BY avg_price DESC;
(Fantasy's The Hobbit was published in 1937, so WHERE published_year >=
1950 drops it, leaving only The Fellowship of the Ring — one row, filtered
out by HAVING COUNT(*) > 1.)
How It Actually Works¶
GROUP BY needs rows with equal keys adjacent to each other so it can
accumulate aggregates. SQLite does this one of two ways: if there's a B-tree
index (or a table's own rowid-implicit ordering) already sorted by the
GROUP BY columns, it streams rows in that order and simply resets the
accumulator every time the key changes — no extra data structure needed. If
not, it builds an ephemeral B-tree keyed by the group columns (same mechanism
as a sort), inserts every row, then walks it in sorted order — you'll see
USE TEMP B-TREE FOR GROUP BY. Each aggregate function (COUNT, SUM,
AVG, MAX) keeps a tiny running accumulator per group: SUM and COUNT
are single running totals updated per row; AVG internally tracks both a sum
and a count and divides only at the end; MAX/MIN just compare-and-replace.
HAVING runs after grouping is complete, filtering finished accumulator
rows — which is exactly why it can reference aggregate results that WHERE
(evaluated per-row, before grouping) cannot.
🔀 See this in another language¶
Exercise¶
Using the books table:
- Count how many books exist per
genre. - Find the average price per
genre, rounded to 2 decimal places. - Find genres that have more than one book, showing genre and count.
- Find the earliest (
MIN) and latest (MAX)published_yearper genre. - Find genres whose average price is above $8.50, ordered by average price descending.