SQL GROUP BY: how grouping and aggregate functions actually work

GROUP BY is where SQL stops being a way to list rows and starts being a way to answer questions about them. This page explains what it really does, walks through the mistakes that trip up almost everyone, and gives you ten exercises you can run right here against real tables.

Every example and exercise on this page runs on SQLite 3.52. Where SQLite behaves differently from PostgreSQL or MySQL — and on one important point it does — that is called out explicitly.

What GROUP BY actually does

A plain SELECT gives you back rows. One row in the table becomes one row in the result. GROUP BY changes that contract: it collapses many rows into one row per group, and you choose what to group on.

Think of a table of employees where each row is one person. If you group by department, SQLite sorts the rows into piles — one pile per distinct department value — and then hands you a single row for each pile. The question it answers is no longer "who works here?" but "what is true of each department?"

That is the whole idea. Everything else about GROUP BY follows from it: because you get one row per pile, every column you select has to make sense for a whole pile rather than for one person.

SELECT department, COUNT(*)
FROM employees
GROUP BY department;
Five employee rows become three result rows — one per department — and COUNT(*) reports how many rows landed in each pile.

Why grouping is needed at all

You can already filter with WHERE and sort with ORDER BY, so it is fair to ask what grouping adds. The answer is that WHERE and ORDER BY both operate on individual rows. Neither can compare a row to its neighbours, and neither can produce a number that describes a set.

Almost every real question about data is a question about a set: how many orders did each customer place, what did each category earn, which class has the highest average. None of those can be written with WHERE alone, because the answer does not exist in any single row — it only exists once rows are combined.

GROUP BY is the mechanism that creates those sets, and aggregate functions are what read a value out of each one.

GROUP BY and aggregate functions are two halves of one idea

An aggregate function takes many values and returns one. SQLite's core five are COUNT, SUM, AVG, MIN and MAX. On their own, with no GROUP BY, they treat the entire table as a single group and give you exactly one row back.

Add GROUP BY and the same functions run once per group instead of once per table. That is the only thing that changes. If you understand what COUNT(*) does to a whole table, you already understand what it does to a group.

Two details about counting are worth committing to memory, because they are a common source of quietly wrong answers. COUNT(*) counts rows — every row in the group, including rows where columns are NULL. COUNT(column) counts non-NULL values in that column, so it can legitimately return a smaller number than COUNT(*) for the very same group.

SUM and AVG also skip NULLs rather than treating them as zero. A column with four numbers and one NULL has an AVG computed over four values, not five. That is usually what you want, but only if you know it is happening.

  • COUNT(*) — how many rows are in this group, NULLs included
  • COUNT(column) — how many non-NULL values this column has in this group
  • SUM(column) — total of the non-NULL values
  • AVG(column) — mean of the non-NULL values; always returns a floating-point number in SQLite, even when every input is an integer
  • MIN(column) / MAX(column) — smallest and largest non-NULL value

The mistakes almost everyone makes

The first and biggest one: selecting a column that is not in the GROUP BY and is not inside an aggregate function. Written out, it looks harmless — SELECT name, department, COUNT(*) FROM employees GROUP BY department. But the HR group contains several different names, and the query asks for one. Which name should it return?

PostgreSQL and MySQL in its default modern configuration refuse this query outright with an error. SQLite does not. It accepts it and returns an arbitrary row's value for the bare column. This is a documented SQLite behaviour, not a bug — and it is the single most dangerous thing about learning GROUP BY on SQLite, because your query appears to work while quietly reporting a value you did not choose.

There is one exception, and it is genuinely useful: when the query uses MIN() or MAX(), SQLite guarantees that bare columns come from a row that actually produced that minimum or maximum. So selecting a name alongside MAX(salary) does give you the name of the highest-paid person in the group. Outside of MIN and MAX, treat any bare column as unreliable.

The second common mistake is trying to filter on an aggregate inside WHERE — writing WHERE COUNT(*) > 1 and getting an error. WHERE runs before grouping happens, so at that point no counts exist yet. The clause you want is HAVING.

The third is assuming the results come back in a sensible order. They often look sorted, because of how SQLite computes groups internally, but nothing guarantees it. If order matters, say so with ORDER BY.

A smaller one worth knowing: rows where the grouping column is NULL do not disappear. SQLite treats all NULLs as belonging to a single group, so you get one NULL row in the output alongside the real values.

-- Reliable: name comes from the row that produced MAX(salary)
SELECT name, department, MAX(salary)
FROM employees
GROUP BY department;

-- Unreliable on SQLite, an error on PostgreSQL/MySQL:
-- `name` is an arbitrary row's value
SELECT name, department, COUNT(*)
FROM employees
GROUP BY department;
The MIN/MAX exception is specific and documented. Everything outside it is arbitrary — do not rely on it.

GROUP BY vs ORDER BY

These two get confused constantly, usually because both involve putting rows together by a shared value. They do completely different things.

ORDER BY rearranges rows. Ten rows in, ten rows out, in a different sequence. Nothing is combined and nothing is lost.

GROUP BY collapses rows. Ten rows in, one row per distinct group out. Information about individual rows is deliberately thrown away, and what you get instead is a summary.

They are not alternatives, and they work well together: group first to get one row per department, then order those grouped rows by the count to see the largest department first.

SELECT department, COUNT(*) AS headcount
FROM employees
GROUP BY department
ORDER BY headcount DESC;
GROUP BY builds the summary rows; ORDER BY decides what sequence you read them in. Note the alias — without one the column is literally named COUNT(*).

Where HAVING fits

HAVING is the filter that runs after grouping. WHERE is the filter that runs before it. That single sentence explains every difference between them.

Because WHERE runs first, it decides which rows are allowed into the piles. Because HAVING runs afterwards, it decides which finished groups survive — and it is the only place an aggregate can be tested, since aggregates do not exist until grouping has happened.

The practical order SQLite applies is: FROM to get the rows, WHERE to discard rows, GROUP BY to build the groups, HAVING to discard groups, SELECT to produce the output columns, then ORDER BY to sequence them.

A query can use both, and often should. Filtering rows early with WHERE is usually cheaper than grouping them and throwing the groups away afterwards.

SELECT department, COUNT(*) AS headcount
FROM employees
WHERE salary > 5          -- filters rows, before grouping
GROUP BY department
HAVING COUNT(*) > 1;      -- filters groups, after grouping
WHERE cannot see COUNT(*), and HAVING cannot bring back rows that WHERE already removed. The order is fixed.

Ten GROUP BY exercises

Each exercise below shows the table you are querying, the task, and the result your query should produce. Write your answer, run it, and compare. If you get stuck, the worked explanation under each exercise says why the answer is the answer.

1. Count employees by department

Count the number of employees in each department in the employees table. Return the department and the count.

Table: employees

id name department
1 Alice HR
2 Bob Engineering
3 Clara HR
4 David Engineering
5 Eve Sales

Expected result

department COUNT(*)
Engineering 2
HR 2
Sales 1
Why this is the answer
Grouping on department produces three piles: Engineering with two rows, HR with two, and Sales with one. COUNT(*) reports the size of each pile. Five input rows become three output rows — the clearest possible demonstration that GROUP BY collapses rather than reorders. Note that the result column is literally named COUNT(*), because nothing aliased it.

2. Total stock by item name

Find the total stock for each item in the items table, grouping by name. Return the name and the total.

Table: items

id name price stock
1 Chips 1.5 100
2 Soda 1 200
3 Chips 1.5 50
4 Soda 1 75

Expected result

name SUM(stock)
Chips 150
Soda 275
Why this is the answer
The same product appears on two rows, which is exactly the situation SUM exists for: 100 + 50 for Chips, 200 + 75 for Soda. Counting here would answer a different question — how many rows mention each item — so pick the aggregate that matches what you were actually asked.

3. Average grade by class

Find the average grade in each class from the students table. Return the class and the average.

Table: students

id name class grade
1 Rita 6B 91
2 Sara 6A 87
3 Leo 6B 95
4 Paul 6A 77

Expected result

class AVG(grade)
6A 82
6B 93
Why this is the answer
AVG over (87, 77) gives 82 and over (91, 95) gives 93. Both happen to land on whole numbers here, but AVG always returns a floating-point value in SQLite even when every input is an integer — exercise 7 shows what that looks like when the division is not clean.

4. Count users by username

Count how many users exist for each username in the users table. Return the username and the count.

Table: users

id username
1 harry
2 hermione
3 ron
4 harry

Expected result

username COUNT(*)
harry 2
hermione 1
ron 1
Why this is the answer
This is the duplicate-detection pattern, and it is one of the most useful things GROUP BY does. Every group with a count above one is a duplicate. Add HAVING COUNT(*) > 1 and you have a report of exactly the usernames that were registered twice.

5. Movies per genre

Get the number of movies per genre in the movies table. Return the genre and the count.

Table: movies

id name genre
1 Frozen Animation
2 Jaws Thriller
3 Inception Sci-Fi
4 Titanic Romance
5 Toy Story Animation

Expected result

genre COUNT(*)
Animation 2
Romance 1
Sci-Fi 1
Thriller 1
Why this is the answer
Four groups from five rows, because only Animation has more than one member. A group of one is still a group — GROUP BY does not skip values just because they appear once, which is what makes the output a complete breakdown rather than a list of repeats.

6. Total score by test name

Get the total score per test name in the tests table. Return the name and the total.

Table: tests

id name score
1 Quiz1 80
2 Quiz2 90
3 Quiz1 75
4 Quiz2 85

Expected result

name SUM(score)
Quiz1 155
Quiz2 175
Why this is the answer
80 + 75 for Quiz1, 90 + 85 for Quiz2. Structurally identical to exercise 2, which is the point: once you can see the grouping column and the aggregated column, the shape of the query is always the same regardless of what the table is about.

7. Average price by product name

Get the average price per product name in the products table. Return the name and the average.

Table: products

id name price
1 Pen 1.2
2 Notebook 4.5
3 Pen 1.4
4 Notebook 4

Expected result

name AVG(price)
Notebook 4.25
Pen 1.2999999999999998
Why this is the answer
The average of 1.2 and 1.4 displays as 1.2999999999999998, not 1.3. That is not an error in your query and not a quirk of this site — it is ordinary binary floating-point arithmetic, and every database that stores REAL values does it. The value shown here is exactly what SQLite returns, left unrounded so the page and the editor agree. When a result is for human eyes, wrap it: ROUND(AVG(price), 2) gives 1.3. Do not round while you are still calculating, only when you are presenting.

8. Minimum rating by genre

Find the minimum rating for each genre in the movies table. Return the genre and the minimum.

Table: movies

id name genre rating
1 Frozen Animation 7.5
2 Toy Story Animation 8
3 Jaws Thriller 8
4 Inception Sci-Fi 8.8
5 Titanic Romance 7.8

Expected result

genre MIN(rating)
Animation 7.5
Romance 7.8
Sci-Fi 8.8
Thriller 8
Why this is the answer
Only Animation has two rows to choose between, so only there does MIN do real work. Thriller's 8.0 comes back as 8 because SQLite drops a trailing zero when the value is a whole number — the stored value is unchanged. This exercise is also where SQLite's one reliable exception applies: had you selected the movie name alongside MIN(rating), SQLite guarantees it would be the name of the film that actually holds that minimum. That guarantee exists only for MIN and MAX.

9. Count books by author

Count the number of books for each distinct author in the books table. Return the author and the count.

Table: books

id title author
1 Matilda Roald Dahl
2 The Hobbit J. R. R. Tolkien
3 The BFG Roald Dahl
4 1984 George Orwell

Expected result

author COUNT(*)
George Orwell 1
J. R. R. Tolkien 1
Roald Dahl 2
Why this is the answer
The word "distinct" in the prompt is already handled by GROUP BY — each distinct author becomes exactly one group, so SELECT DISTINCT is neither needed nor helpful here. Selecting title alongside the count would be the classic mistake: Roald Dahl's group holds two titles and the query only has room for one, so SQLite would silently pick one of them.

10. Stock sum by price

Get the sum of stock grouped by price from the items table. Return the price and the total stock.

Table: items

id name price stock
1 Chips 1.5 100
2 Soda 1 200
3 Chocolate 1.5 150
4 Cake 2.5 30

Expected result

price SUM(stock)
1 200
1.5 250
2.5 30
Why this is the answer
The last exercise deliberately groups on something that is not a category. Chips and Chocolate are different products, but they share a price of 1.5, so they land in the same group and their stock is summed to 250. Nothing about GROUP BY requires the grouping column to be a label — it groups on whatever values are equal. Getting a group that mixes two products is the correct answer to the question that was asked, and noticing that is the difference between writing SQL and understanding it.

Where to go from here

If the exercises above are making sense, the next things worth learning are HAVING — filtering the groups you just built — and JOINs, which let you group across more than one table. Both are on the same footing as this page: a concept that only really lands once you have run it yourself a dozen times.

xCodeClazz runs live, structured coding courses where you write SQL with an instructor watching, rather than alone against a page. If that is the kind of learning that works for you, the current programmes are listed on the courses page.

xCodeClazz

Learn Coding, Compete, Get Rewarded

© 2026 Raisehand Software Private Limited. All rights reserved.