Lesson 9 of 20

Aggregate Functions

One Row Out of Many

Every query so far has returned one output row per input row. An aggregate function breaks that rule: it reads a whole set of rows and produces a single value from them. There are five you will use constantly — COUNT, SUM, AVG, MIN and MAX — and between them they answer most of the questions anyone ever asks about a table.

The important consequence is that the shape of the result changes. SELECT marks FROM students gives you one row per student. SELECT AVG(marks) FROM students gives you exactly one row, whether the table holds five students or five million. With no GROUP BY present, the entire table — after WHERE has filtered it — counts as one single group.

That is also why you cannot casually mix aggregated and non-aggregated columns. SELECT name, AVG(marks) FROM students asks for many rows and one row in the same breath, and the databases do not agree on how to respond. PostgreSQL, SQL Server, Oracle and a correctly configured MySQL all reject it. A loosely configured MySQL runs it and prints some arbitrary student's name beside the class average, which is worse than an error because the output looks perfectly reasonable.

The next lesson introduces GROUP BY, which is the proper way to ask for one average per branch rather than one average for everybody. For now, hold on to the mental picture: an aggregate takes a pile of rows and hands back a single number.

Example
-- One row per student
SELECT name, marks FROM students;

-- One row, full stop
SELECT AVG(marks) AS class_average FROM students;

-- Several aggregates computed in one pass over the table
SELECT
    COUNT(*)   AS students,
    MIN(marks) AS lowest,
    MAX(marks) AS highest,
    AVG(marks) AS average,
    SUM(marks) AS total_marks
FROM students;

-- This asks for one row and many rows at the same time.
-- Most databases reject it; a loose MySQL returns a misleading answer.
-- SELECT name, AVG(marks) FROM students;
Notes
  • MySQL's behaviour here is controlled by a setting called ONLY_FULL_GROUP_BY, part of the default SQL mode since MySQL 5.7. If a query like the last one runs on your laptop and errors on a classmate's, that setting is the difference. Leave it switched on — it is the standard-compliant behaviour and it catches real bugs before your users do.

COUNT Has Three Forms and They Do Not Agree

COUNT(*) counts rows. It never looks inside them, so a row consisting entirely of NULLs still counts as one. This is the form to reach for when the question is simply "how many records are there".

COUNT(column) counts the rows where that column is not NULL. This is not a technicality — it is the source of an entire family of quietly wrong reports. If students holds 50 rows and 8 of them have no phone number, COUNT(*) is 50 and COUNT(phone) is 42. Both are correct answers to different questions, and choosing the wrong one produces a number that nobody notices is wrong.

COUNT(DISTINCT column) counts how many different non-NULL values appear. "How many branches are represented in this class?" is COUNT(DISTINCT branch). Writing COUNT(branch) there would just count students again, and would look plausible until somebody checks it by hand.

Two side points worth having ready for an interview. COUNT(1) and COUNT(*) are identical on every current database; the folklore that one is faster comes from optimisers that were retired decades ago. And COUNT(*) on a very large InnoDB table is not free — MySQL may have to walk an entire index to produce an exact figure, which is why big systems often keep a maintained counter elsewhere instead of asking for the true count on every page load.

Example
-- All three forms, side by side
SELECT
    COUNT(*)               AS all_rows,
    COUNT(phone)           AS have_a_phone,
    COUNT(DISTINCT branch) AS branches
FROM students;

-- The gap between the first two is exactly the number of NULLs
SELECT COUNT(*) - COUNT(phone) AS missing_phone FROM students;
SELECT COUNT(*) AS missing_phone FROM students WHERE phone IS NULL;

-- What share of students gave a phone number?
-- Multiplying by 100.0 keeps the division out of whole numbers
SELECT ROUND(100.0 * COUNT(phone) / COUNT(*), 1) AS percent_with_phone
FROM students;

-- COUNT(branch) is NOT the number of branches - it is the number of
-- students whose branch is filled in
SELECT COUNT(branch)          AS students_with_a_branch FROM students;
SELECT COUNT(DISTINCT branch) AS how_many_branches      FROM students;
Notes
  • COUNT never returns NULL. On an empty table it returns 0, which makes it one of the few places in SQL where "nothing" comes back as a number instead of as NULL. SUM and AVG do not behave that way, as the next section explains.

SUM and AVG, and the NULLs They Skip

SUM and AVG work on numbers, and both ignore NULL entirely. For SUM that is nearly always what you want, since skipping an unknown value has the same effect as adding zero for it.

For AVG it is subtler, and it changes the answer. AVG(marks) divides the total by the number of rows where marks is not NULL, not by the number of students. If 50 students are enrolled and 10 have not been marked yet, the average is over 40 people. That is very often correct — an unmarked student should not be treated as having scored zero — but it is only correct if you know it is happening. If you did want blanks counted as zeros, say so with AVG(COALESCE(marks, 0)) and watch the number move.

Both functions return NULL, not 0, when there is nothing to add up. SELECT SUM(total_amount) FROM orders WHERE status = 'refunded' in a month with no refunds gives you NULL. Feed that straight into a report and the page shows "null"; feed it into application code and you may get a crash. Wrap it: COALESCE(SUM(total_amount), 0).

Watch the data type too. In MySQL, dividing two integers produces a decimal result, so SUM(marks) / COUNT(*) behaves. In PostgreSQL, dividing two integers performs integer division and discards the remainder, so the same expression can quietly return 78 where the true average is 78.9. Multiplying one side by 1.0, or casting it, forces decimal arithmetic and removes the ambiguity everywhere. Averaging a DECIMAL money column is exact; averaging a FLOAT column inherits every rounding problem floating point has.

Example
-- AVG divides by the number of non-NULL marks, not by the row count
SELECT
    COUNT(*)                AS students,
    COUNT(marks)            AS marked,
    SUM(marks)              AS total,
    AVG(marks)              AS avg_over_marked,
    AVG(COALESCE(marks, 0)) AS avg_treating_blank_as_zero
FROM students;

-- Nothing to add up returns NULL, not zero
SELECT SUM(total_amount) FROM orders WHERE status = 'refunded';

-- Say what should happen instead
SELECT COALESCE(SUM(total_amount), 0) AS refunded_total
FROM orders
WHERE status = 'refunded';

-- Force decimal arithmetic so the division is never truncated
SELECT SUM(marks) * 1.0 / COUNT(*) AS avg_over_everyone FROM students;

-- Round for display, not for storage
SELECT ROUND(AVG(marks), 2) AS class_average FROM students;
Notes
  • AVG(x) and SUM(x) / COUNT(*) are different calculations whenever x can be NULL: the first divides by COUNT(x), the second by COUNT(*). When two people compute "the average" and get two different numbers, this is nearly always the reason.

MIN, MAX, and Finding the Row That Holds Them

MIN and MAX accept any type that can be compared. On numbers they give the smallest and largest, on dates the earliest and latest, on text the first and last in the column's collation order. MAX(joined_on), answering "when did the most recent student join", is one of the most useful one-line queries in the language.

Which leads to the question everybody asks next: who scored the maximum? The natural-looking SELECT name, MAX(marks) FROM students does not answer it. Nothing in that query ties the name to the maximum — you have asked for one arbitrary name and, quite separately, for the highest mark in the table. Standard-compliant databases refuse it. A MySQL with ONLY_FULL_GROUP_BY disabled runs it and returns a name that may belong to an entirely different student.

There are two correct patterns. Sort and take the top row with ORDER BY marks DESC LIMIT 1, which is short and very fast when marks is indexed, but returns only one row when several students tie. Or filter against a subquery — WHERE marks = (SELECT MAX(marks) FROM students) — which returns everybody who tied. Choosing between them is a real decision rather than a style preference: joint toppers are not a rare edge case.

MIN and MAX skip NULL like the rest, which here is usually helpful — the lowest mark is the lowest mark actually recorded, not "unknown". If a set contains no non-NULL values at all, they return NULL, exactly as SUM and AVG do.

Example
-- Any comparable type works
SELECT
    MIN(marks)     AS lowest_mark,
    MAX(marks)     AS highest_mark,
    MIN(joined_on) AS first_joined,
    MAX(joined_on) AS most_recent,
    MIN(name)      AS first_alphabetically
FROM students;

-- WRONG: name and MAX(marks) are not connected to each other
-- SELECT name, MAX(marks) FROM students;

-- Right, version 1: exactly one row, tie broken by id
SELECT name, marks
FROM students
ORDER BY marks DESC, id ASC
LIMIT 1;

-- Right, version 2: every student who tied for the top mark
SELECT name, marks
FROM students
WHERE marks = (SELECT MAX(marks) FROM students);

-- The same shape answers "the latest order overall"
SELECT id, student_id, ordered_at
FROM orders
ORDER BY ordered_at DESC, id DESC
LIMIT 1;
Notes
  • MAX on a text column compares by collation order, not by length or by meaning. MAX(branch) over 'CSE', 'ECE' and 'IT' returns 'IT', because I sorts after E. That is rarely what you actually wanted from a text column, though it is occasionally handy when you need a deterministic single value and do not care which.

Filtering Rows Before You Aggregate

WHERE runs before the aggregate. It decides which rows enter the calculation, and the aggregate then reduces whatever survived. That ordering is the entire reason SELECT AVG(marks) FROM students WHERE branch = 'CSE' works: 50 rows are filtered down to 12, and the average is computed over those 12.

The corollary is that you cannot put an aggregate inside WHERE. WHERE marks > AVG(marks) is an error, because at the moment WHERE is evaluated the average does not exist yet — the rows have not all been read. To compare each row against an aggregate of the whole table, compute the aggregate in a subquery: WHERE marks > (SELECT AVG(marks) FROM students). To filter on an aggregate of a group, you need HAVING, which is where the next lesson starts.

One technique is worth learning right now because it appears in every reporting job: put a CASE expression inside the aggregate. SUM(CASE WHEN branch = 'CSE' THEN 1 ELSE 0 END) counts CSE students, and several such expressions can sit side by side to build a small summary table from a single pass over the data. It is faster than running four separate queries and it keeps the whole answer on one row.

The COUNT version of the same trick leans on the NULL rule from earlier. COUNT(CASE WHEN marks >= 85 THEN 1 END) counts only the rows where the CASE produced a value, because the missing ELSE yields NULL for everyone else and COUNT ignores NULLs. This is one of the very few places where leaving out the ELSE is deliberate rather than a bug.

Example
-- WHERE first, aggregate second
SELECT COUNT(*) AS cse_students, AVG(marks) AS cse_average
FROM students
WHERE branch = 'CSE';

-- Not allowed: the average does not exist while WHERE is running
-- SELECT name FROM students WHERE marks > AVG(marks);

-- Correct: work the average out in a subquery first
SELECT name, marks
FROM students
WHERE marks > (SELECT AVG(marks) FROM students)
ORDER BY marks DESC;

-- Conditional aggregation: one pass, several answers
SELECT
    COUNT(*)                                          AS total,
    SUM(CASE WHEN branch = 'CSE' THEN 1 ELSE 0 END)   AS cse,
    SUM(CASE WHEN branch = 'ECE' THEN 1 ELSE 0 END)   AS ece,
    COUNT(CASE WHEN marks >= 85 THEN 1 END)           AS distinctions,
    ROUND(AVG(CASE WHEN branch = 'CSE' THEN marks END), 1) AS cse_average
FROM students;
Notes
  • MySQL lets you shorten the boolean form to SUM(branch = 'CSE'), because it treats true as 1 and false as 0. It is compact and it is MySQL-only. PostgreSQL and SQL Server need the full CASE expression, and PostgreSQL also offers COUNT(*) FILTER (WHERE branch = 'CSE'). Write the CASE version if the query might ever move to another engine.

Presenting the Numbers, and Two Functions That Travel Badly

Aggregates produce numbers that people read, so a little care with presentation pays off. ROUND(value, 2) is right for display; do not round when storing, and do not round in the middle of a chain of calculations, because small errors compound. Round once, at the end.

Aggregates also nest inside other queries. An aggregate in a subquery becomes an ordinary value that the outer query can compare against, which is how "orders above the average order value" gets written. What you cannot do is nest one aggregate directly inside another: MAX(AVG(marks)) is not valid without a GROUP BY underneath it, so the subquery is the tool for that job as well.

Collapsing several rows into one comma-separated string is a common request and is spelled differently in every product. MySQL has GROUP_CONCAT; PostgreSQL and SQL Server 2017 and later have STRING_AGG; Oracle has LISTAGG. There is no portable spelling. MySQL's version carries a length limit set by group_concat_max_len — 1024 bytes by default — and it truncates the result silently when the list runs longer, which is a genuinely nasty thing to discover inside a finished report.

Finally, be honest about what an aggregate hides. An average shown without a count beside it is close to meaningless: a product rated 5.0 by one buyer is not better than one rated 4.6 by four hundred. Whenever you put an aggregate in front of a user, put COUNT(*) next to it. That one habit prevents most misleading dashboards.

Example
-- An aggregate in a subquery is just a value to the outer query
SELECT id, total_amount
FROM orders
WHERE total_amount > (SELECT AVG(total_amount) FROM orders)
ORDER BY total_amount DESC;

-- Always show the count alongside the average
SELECT
    COUNT(*)                    AS orders_counted,
    ROUND(AVG(total_amount), 2) AS avg_order_value,
    MIN(total_amount)           AS smallest,
    MAX(total_amount)           AS largest
FROM orders
WHERE status <> 'cancelled';

-- Collapsing rows into a single string is dialect-specific
-- MySQL
SELECT GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') AS cse_students
FROM students
WHERE branch = 'CSE';

-- PostgreSQL, and SQL Server 2017+
-- SELECT STRING_AGG(name, ', ' ORDER BY name) AS cse_students
-- FROM students WHERE branch = 'CSE';
Notes
  • Look closely at WHERE status <> 'cancelled' in the second query. If status can be NULL, that condition silently drops the rows whose status is unknown, because NULL <> 'cancelled' evaluates to unknown rather than to true. When unknown should be included, write WHERE status IS NULL OR status <> 'cancelled'.
Ask AI