GROUP BY: One Row Per Group
The previous lesson ended on a limitation. An aggregate collapses a whole table into one number, which is useful, but most real questions are per-something. Marks per branch. Orders per student. Revenue per month. GROUP BY is how you say per.
Picture it physically. The database takes the rows that survived WHERE, drops them into buckets — one bucket for each distinct value of the grouping column — and then runs your aggregate separately inside every bucket. The result has exactly one row per bucket. Group 50 students by branch and you get one row for CSE, one for ECE, one for IT, each carrying its own count and its own average.
Everything you learned about aggregates still holds inside each group. COUNT(*) counts the rows in that group, COUNT(phone) counts the non-NULL phone numbers in that group, and AVG(marks) divides by the number of marked students in that group. The NULL rules do not soften just because a GROUP BY is present.
One habit pays for itself immediately: include COUNT(*) in every grouped query, even when nobody asked for it. It tells you how large each group is, and the size of a group is what tells you whether the other numbers on that row mean anything at all. A branch average calculated from two students is not comparable with one calculated from forty.
-- One row per branch
SELECT branch, COUNT(*) AS students
FROM students
GROUP BY branch;
-- Aggregates behave inside each group exactly as they did over the table
SELECT
branch,
COUNT(*) AS students,
COUNT(marks) AS marked,
ROUND(AVG(marks), 1) AS average,
MIN(marks) AS lowest,
MAX(marks) AS highest
FROM students
GROUP BY branch;
-- Per student rather than per branch, over a different table
SELECT student_id, COUNT(*) AS orders, SUM(total_amount) AS spent
FROM orders
GROUP BY student_id; GROUP BY branchandSELECT DISTINCT branchreturn the same list of branches. UseDISTINCTwhen you only want the values themselves; useGROUP BYwhen you want to compute something for each one. A query containing both is almost always a query that has been patched rather than thought through.
Every Selected Column Must Be Grouped or Aggregated
One rule produces more beginner errors than anything else in SQL, and it follows directly from the bucket picture. Every column in your SELECT list must either appear in the GROUP BY, or be wrapped in an aggregate. Nothing else is meaningful: a bucket holding twelve CSE students contains twelve different names, so SELECT branch, name, COUNT(*) ... GROUP BY branch asks the database to print one name where twelve exist.
The error wording differs by product but the meaning does not. PostgreSQL says "column students.name must appear in the GROUP BY clause or be used in an aggregate function". MySQL with its default settings says "Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column". When you meet it, the fix is to decide what you actually meant: add the column to the GROUP BY so the groups become finer, wrap it in MIN, MAX or COUNT so it reduces to one value, or drop it from the query.
MySQL historically allowed the query anyway and returned an unpredictable value from somewhere inside the group. That behaviour still appears wherever ONLY_FULL_GROUP_BY has been removed from the SQL mode, which some hosting control panels and a lot of old tutorials still do. Treat any query relying on it as broken. It will return different output on a different server, or after somebody adds an index, and nothing will warn you.
There is one legitimate exception. If you group by a table's primary key, every other column of that table is uniquely determined by it — a bucket can only hold one row — so selecting those columns is safe. PostgreSQL has recognised this since 9.1 and MySQL since 5.7, so GROUP BY s.id lets you select s.name without complaint. SQL Server and Oracle do not implement the rule, so there you still list every column.
-- Rejected, and rightly: which of the twelve CSE names should print?
-- SELECT branch, name, COUNT(*) FROM students GROUP BY branch;
-- Option 1: make the groups finer
SELECT branch, name, COUNT(*) AS rows_for_this_person
FROM students
GROUP BY branch, name;
-- Option 2: reduce the column with an aggregate
SELECT branch, MAX(marks) AS top_mark, COUNT(*) AS students
FROM students
GROUP BY branch;
-- Option 3: group by the primary key, which makes the rest unambiguous
-- (accepted by MySQL 5.7+ and PostgreSQL 9.1+)
SELECT s.id, s.name, COUNT(o.id) AS orders
FROM students s
LEFT JOIN orders o ON o.student_id = s.id
GROUP BY s.id;
-- The portable spelling lists every non-aggregated column
SELECT s.id, s.name, COUNT(o.id) AS orders
FROM students s
LEFT JOIN orders o ON o.student_id = s.id
GROUP BY s.id, s.name; - Write the last form by default. Listing every non-aggregated column is accepted by every database, costs nothing, and removes a whole class of "but it works on my machine" bug.
Grouping by Several Columns, and by Expressions
List more than one column and the buckets get finer: one group per distinct combination. GROUP BY branch, is_active gives up to two rows per branch, one for the active students and one for the rest. The order you list the columns in does not change which groups exist, but writing them broadest first matches the way you will read the output.
You can also group by an expression, which is how every time-series report gets written. GROUP BY YEAR(ordered_at), MONTH(ordered_at) produces one row per month. In MySQL, DATE_FORMAT(ordered_at, '%Y-%m') does the same job in a single column. PostgreSQL spells it DATE_TRUNC('month', ordered_at) and extracts parts with EXTRACT(YEAR FROM ordered_at) rather than YEAR(); SQL Server has DATEPART and FORMAT. The idea is identical everywhere and only the function names move.
Whether you may group by a SELECT alias is a real dialect split. MySQL and PostgreSQL both accept GROUP BY month_label where month_label was defined in the SELECT list, even though the logical evaluation order says GROUP BY happens first — it is a deliberate convenience extension. SQL Server and Oracle reject it and want the whole expression written out again. Repeating the expression is never wrong, so do that if the query might travel.
Grouping by an expression carries a performance cost that is worth knowing early. An index on ordered_at cannot help GROUP BY YEAR(ordered_at), because the index stores dates, not years. On a large table that turns a cheap grouped query into a full scan. The indexes lesson returns to this properly; for now, notice the pattern — wrapping a column in a function usually hides it from its own index.
-- Finer buckets: one row per combination
SELECT branch, is_active, COUNT(*) AS students
FROM students
GROUP BY branch, is_active
ORDER BY branch, is_active;
-- A monthly revenue report (MySQL)
SELECT
DATE_FORMAT(ordered_at, '%Y-%m') AS month,
COUNT(*) AS orders,
ROUND(SUM(total_amount), 2) AS revenue
FROM orders
WHERE status <> 'cancelled'
GROUP BY DATE_FORMAT(ordered_at, '%Y-%m')
ORDER BY month;
-- The same idea with separate year and month columns (MySQL)
SELECT YEAR(ordered_at) AS y, MONTH(ordered_at) AS m, COUNT(*) AS orders
FROM orders
GROUP BY YEAR(ordered_at), MONTH(ordered_at)
ORDER BY y, m;
-- PostgreSQL says the same thing differently
-- SELECT DATE_TRUNC('month', ordered_at) AS month, COUNT(*) AS orders
-- FROM orders
-- GROUP BY DATE_TRUNC('month', ordered_at)
-- ORDER BY month; - The
'%Y-%m'format works because a year-first date sorts correctly as ordinary text:'2026-01'comes before'2026-02'alphabetically as well as chronologically. Format the same month as'01/2026'and your ORDER BY silently becomes wrong.
The Groups That Are Not There
GROUP BY can only produce groups that exist in the data. That sounds obvious right up until it breaks a report. If no orders were placed in September, the monthly revenue query returns no row for September — not a row containing zero, no row at all. A chart drawn from that result joins August straight to October, and the missing month is invisible unless somebody counts the bars.
There is no tidy fix inside a single grouped query. The usual approaches are to generate the list of months separately and LEFT JOIN the aggregated figures onto it, or to fill the gaps in application code, which for a chart is often the more honest answer. The important thing is to know the hole is possible. "Zero" and "no row" look identical in a total and completely different in a trend.
NULL behaves unusually here, and for once pleasantly. Everywhere else in SQL a NULL refuses to equal itself; in GROUP BY all the NULLs are gathered into a single group. Students with no branch recorded therefore appear together on one row with an empty branch. That is nearly always what you want, but do notice the row — a mysterious extra line at the top or bottom of a grouped report is usually the NULL group rather than a bug.
A related surprise follows from the evaluation order: because WHERE runs first, a filter that removes all of a group's rows removes the group itself, not just its contents. WHERE marks >= 40 GROUP BY branch will not show a branch where everybody failed with a count of zero; that branch simply will not appear. If you need every branch listed, the condition has to move inside the aggregate as a CASE instead of sitting in the WHERE.
-- Months with no orders are missing entirely, not shown as zero
SELECT DATE_FORMAT(ordered_at, '%Y-%m') AS month, COUNT(*) AS orders
FROM orders
GROUP BY DATE_FORMAT(ordered_at, '%Y-%m')
ORDER BY month;
-- Every NULL branch collects into one group
SELECT branch, COUNT(*) AS students
FROM students
GROUP BY branch;
-- one of the returned rows may have branch = NULL
-- Label that group so nobody has to guess what the blank means
SELECT COALESCE(branch, 'not recorded') AS branch, COUNT(*) AS students
FROM students
GROUP BY COALESCE(branch, 'not recorded');
-- WHERE drops whole groups: a branch where everyone failed vanishes
SELECT branch, COUNT(*) AS passed
FROM students
WHERE marks >= 40
GROUP BY branch;
-- Keep every branch, and count the passes inside the aggregate instead
SELECT
branch,
COUNT(*) AS students,
COUNT(CASE WHEN marks >= 40 THEN 1 END) AS passed
FROM students
GROUP BY branch; - The last query is the conditional-aggregation pattern from the previous lesson doing real work. Moving a condition out of
WHEREand into aCASEinside the aggregate is the standard way to keep every group visible while still counting only some of its rows.
HAVING: Filtering the Groups Themselves
WHERE filters rows before grouping; HAVING filters groups after grouping. That sentence answers the interview question, but the reason behind it is what makes it stick. When WHERE runs, the groups do not exist yet, so there is nothing for an aggregate to be computed over. By the time HAVING runs, each group has already been reduced to one row with its aggregates calculated, and filtering on COUNT(*) is entirely meaningful.
So WHERE marks >= 40 decides which students count, and HAVING COUNT(*) >= 10 decides which branches appear. They are not alternatives, and a good query often uses both: narrow the rows first, then discard the groups that are too small to be worth showing.
When a condition could go in either clause, put it in WHERE. HAVING branch = 'CSE' is legal in most databases and produces the same answer as WHERE branch = 'CSE', but it makes the engine build every group and then throw nearly all of them away. WHERE removes the rows before any grouping work begins. On a large table that difference is easily an order of magnitude, and it is one of the few optimisations you can apply just by moving a line.
Whether HAVING can use a SELECT alias is another dialect split. MySQL and PostgreSQL accept HAVING total > 5 where total was defined as COUNT(*) AS total. SQL Server and Oracle require the expression to be repeated as HAVING COUNT(*) > 5. Repeating it is never wrong, which makes it the safer default.
-- Branches with at least ten students
SELECT branch, COUNT(*) AS students
FROM students
GROUP BY branch
HAVING COUNT(*) >= 10;
-- Both clauses, each doing its own job
SELECT branch, COUNT(*) AS passed, ROUND(AVG(marks), 1) AS avg_of_passers
FROM students
WHERE marks >= 40 -- which students count
GROUP BY branch
HAVING COUNT(*) >= 5 -- which branches are worth showing
ORDER BY avg_of_passers DESC;
-- Students with more than three orders and over 5000 spent in total
SELECT student_id, COUNT(*) AS orders, SUM(total_amount) AS spent
FROM orders
WHERE status <> 'cancelled'
GROUP BY student_id
HAVING COUNT(*) > 3 AND SUM(total_amount) > 5000
ORDER BY spent DESC;
-- Legal but wasteful: builds every group, then discards most of them
-- SELECT branch, COUNT(*) FROM students
-- GROUP BY branch HAVING branch = 'CSE';
-- Do the same filtering here instead
SELECT branch, COUNT(*) AS students
FROM students
WHERE branch = 'CSE'
GROUP BY branch; HAVINGwithoutGROUP BYis legal, because the whole table counts as one group.SELECT COUNT(*) FROM students HAVING COUNT(*) > 100returns a row only when there are more than a hundred students. It is a curiosity rather than something you will write often, but it explains why the two clauses are not tied together syntactically.
Reading a Grouped Query in the Order It Runs
Grouped queries stop being confusing the moment you can recite the logical evaluation order: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT. That is not the order you write the clauses in, and it is not necessarily the order the engine physically performs the work in — it is the order that defines what each clause is allowed to see.
Almost every awkward behaviour in this lesson falls straight out of that list. An aggregate cannot appear in WHERE, because WHERE runs before GROUP BY. A SELECT alias generally cannot be used in WHERE, because SELECT runs later. The same alias works in ORDER BY, because ORDER BY runs last. And once a GROUP BY is present, LIMIT counts groups rather than rows, because by then each group has become a single row.
That last point deserves a moment. GROUP BY branch ORDER BY COUNT(*) DESC LIMIT 3 returns the three largest branches, not the first three students. Sorting by an aggregate is completely normal, and it is how essentially every "top N" report is written.
One thing GROUP BY cannot do by itself is "the top three students within each branch". That needs a window function — ROW_NUMBER() OVER (PARTITION BY branch ORDER BY marks DESC) — available in MySQL 8.0 and later, PostgreSQL, SQL Server and Oracle. Window functions are beyond this course, but knowing the name of the tool means you will find it when you hit the problem, which you eventually will.
-- FROM -> WHERE -> GROUP BY -> HAVING -> SELECT -> ORDER BY -> LIMIT
SELECT branch, COUNT(*) AS students, ROUND(AVG(marks), 1) AS average
FROM students -- 1. the rows come from here
WHERE is_active = 1 -- 2. rows are filtered
GROUP BY branch -- 3. rows are bucketed
HAVING COUNT(*) >= 5 -- 4. small buckets are dropped
-- 5. SELECT builds the output row
ORDER BY average DESC -- 6. the alias works, because SELECT ran first
LIMIT 3; -- 7. three groups, not three students
-- Top three branches by student count
SELECT branch, COUNT(*) AS students
FROM students
GROUP BY branch
ORDER BY COUNT(*) DESC
LIMIT 3;
-- The five students who have spent the most
SELECT student_id, SUM(total_amount) AS spent
FROM orders
WHERE status <> 'cancelled'
GROUP BY student_id
ORDER BY spent DESC
LIMIT 5; - Interviewers ask for this order almost word for word. Learn it as a spoken sentence — from, where, group by, having, select, order by, limit — and you can work out nearly every rule about which clause may reference what, instead of memorising them one at a time.
