21. Aggregate functions: COUNT, SUM, AVG and friends
What makes a function "aggregate"
Every function you've used so far (computed columns, upper(),
arithmetic) is row-by-row — one input row produces one
output value, independently of every other row. An aggregate
function is fundamentally different: it consumes many rows and
collapses them into a single value. This is the first genuinely new
computational shape in the course, and it's the foundation for GROUP
BY in the next lesson.
SELECT count(*) AS total_films FROM film; -- one row out, from 1000 rows in
SELECT avg(rental_rate) AS avg_price FROM film; -- one row out, from 1000 rows in
Without a GROUP BY clause (next lesson), an aggregate collapses the
entire table (or filtered result) into one row. This is why SELECT
title, avg(rental_rate) FROM film is an error — title wants one value
per row, avg(rental_rate) wants to collapse all rows into one; mixing
an ungrouped column with an aggregate is a contradiction the engine
rejects outright.
The five you'll use constantly
SELECT
count(*) AS total_films,
sum(rental_rate) AS total_of_all_rates,
avg(rental_rate) AS average_rate,
min(rental_rate) AS cheapest,
max(rental_rate) AS most_expensive
FROM film;
SUM/AVG require a numeric-ish input; MIN/MAX work on anything
orderable (numbers, text, dates) — MIN(title) returns the
alphabetically-first title, MAX(rental_date) returns the most recent
rental.
COUNT(*) vs COUNT(column) — a real, common gotcha
count(*) counts rows, full stop — every row, NULLs and all.
count(column) counts non-NULL values in that column specifically —
aggregates skip NULL (the "unknown" value from Module 1). These are not the
same question, and the difference matters the moment a column can be
NULL:
-- Every rental has a rental_id, so these two agree here:
SELECT count(*) AS all_rows, count(rental_id) AS non_null_rental_ids FROM rental;
-- return_date CAN be NULL (a rental not yet returned) — these DIVERGE:
SELECT count(*) AS all_rentals, count(return_date) AS returned_rentals FROM rental;
-- The gap between these two numbers is exactly "rentals not yet returned."
That gap is itself a useful, real number — count(*) - count(return_date)
directly answers "how many outstanding rentals are there," entirely as a
side effect of understanding what each COUNT variant actually counts.
count(DISTINCT column) counts distinct non-NULL values — combining
the DISTINCT dedup behavior from Module 1 with aggregation:
SELECT count(DISTINCT rating) AS distinct_ratings FROM film; -- 5
SELECT count(rating) AS total_rating_values FROM film; -- 1000 (every film has a rating)
Aggregates and three-valued logic, together
A subtlety worth internalizing now rather than debugging later: SUM,
AVG, MIN, MAX on a column with zero non-NULL rows return NULL,
not zero. This trips people up constantly when the aggregate result
feeds into further arithmetic:
-- If no rows matched this filter at all, sum() returns NULL, not 0.
-- (film_id = -1 matches no row by construction — film_id is a positive serial.)
SELECT sum(rental_rate) FROM film WHERE film_id = -1;
-- result: NULL — there were zero rows to sum, so "the sum" is genuinely unknown/undefined,
-- not "zero dollars"
(Aside: pagila's rating column is actually a Postgres enum type
[mpaa_rating], not plain text — trying to compare it against a string
that isn't one of its five defined values raises a real error rather
than just matching zero rows. Custom enum types aren't covered further in
this course; this is just a heads-up so rating = 'something-made-up'
doesn't surprise you with an error instead of an empty result the way it
would on a genuinely text-typed column.)
COALESCE(sum(rental_rate), 0) (Module 1's NULL lesson) is the standard guard when
you specifically want "zero" to mean "nothing matched," e.g. in a
financial report where a blank should read as $0.00, not a blank cell.
Check yourself
- What's the difference between
count(*)andcount(some_column), precisely? - If a filter matches zero rows, what does
SUM(...)over that empty result return — zero, or something else? - Using
rental.return_date, write a single query that reports both the total rental count and the count of outstanding (not-yet-returned) rentals, without a second query.