Counting and Summing: GROUP BY
"How many bookings does each screening have?" Look closely and this is a different kind of question from everything so far. Every answer in this chapter has been made of rows from a table — this film, that customer. But the rows of this answer aren't bookings; they're summaries of bookings, one row per screening, with a count attached.
Two tools make that possible. An aggregate function — COUNT, SUM, AVG, MIN, MAX — collapses many rows into one number. And GROUP BY decides what "many" means: without it, the aggregate swallows the whole table; with it, the table is dealt into groups and the aggregate runs once per group. This page is how every dashboard number in the world actually gets made.
Collapsing a Table to One Number
Start with the simplest aggregate question: how many bookings are there, in total? The function COUNT(*) counts rows, and with no grouping in sight it counts all of them:
SELECT COUNT(*) FROM bookings;
| COUNT(*) |
|---|
| 54 |
Fifty-four bookings, one row, one number. Notice how strange that answer is by this chapter's standards: no booking appears in it. The whole table collapsed into a single summary row. The other aggregates behave the same way — SUM(price) adds a column up, AVG(price) averages it, MIN(joined_on) and MAX(joined_on) find the earliest and latest — each one eats many rows and hands back one value.
GROUP BY Changes the Unit
Lora's real question wasn't the grand total, though — it was per screening. GROUP BY is how you say "per": deal the rows into one pile for each distinct value of a column, then run the aggregate once per pile. Sorting the till receipts is exactly this — one pile per film night, then count each pile. The piles are GROUP BY; the counting is the aggregate. Keep the picture for a page, because we drew it from real mechanics:
SELECT screening_id, COUNT(*) AS bookings_count FROM bookings GROUP BY screening_id;
Walk the mechanics slowly with a small excerpt of the bookings table. Eight bookings, spread over three screenings:
| booking_id | screening_id | seat |
|---|---|---|
| 31 | 1 | G7 |
| 32 | 1 | G8 |
| 33 | 3 | B2 |
| 34 | 1 | C4 |
| 35 | 2 | D1 |
| 36 | 3 | B3 |
| 37 | 1 | F5 |
| 38 | 3 | A6 |
GROUP BY screening_id deals these eight rows into three piles — the screening-1 pile holds four rows, the screening-2 pile holds one, the screening-3 pile holds three. Then COUNT(*) runs once per pile, and each pile becomes exactly one output row:
| screening_id | bookings_count |
|---|---|
| 1 | 4 |
| 2 | 1 |
| 3 | 3 |
SUM works the same way with money. Group the screenings table by film and add up the ticket prices, and you get each film's prices totaled across its showings — call it the one-seat revenue, what a film would earn if exactly one ticket sold per screening. Real revenue would need each booking matched to its screening's price, which takes two tables at once; and turning film_id into a title needs the films table too. Both honestly need Chapter 4 — one page away, and now you know why it exists:
SELECT film_id, SUM(price) AS one_seat_revenue FROM screenings GROUP BY film_id;
| film_id | one_seat_revenue |
|---|---|
| 1 | 12.00 |
| 2 | 10.00 |
| 3 | 21.50 |
The Iron Rule
Now the rule that every beginner meets as an error message before meeting it as an idea: in a grouped query, every column you SELECT must either be listed in the GROUP BY or sit inside an aggregate. Try to sneak seat into the bookings-per-screening query and the database refuses to run it.
The refusal is not pedantry — ask what the database would have to do. The screening-1 pile contains four bookings with four different seats: G7, G8, C4, F5. The answer has one row for that pile. Which seat should the row show? There is no honest answer, so the database refuses to guess. Grouped columns are safe to show because every row in a pile shares their value; aggregated columns are safe because the pile was summarized into one value. Everything else is ambiguous, and the iron rule is simply the database declining to invent data.
COUNT(*) vs COUNT(column)
One last distinction, small-looking and interview-famous. COUNT(*) counts rows. COUNT(email) counts non-NULL values in the email column — rows where email is missing simply aren't counted. On the Marquee's customers table the two disagree by exactly the three regulars with no email on file:
SELECT COUNT(*) AS all_customers,
COUNT(email) AS customers_with_email
FROM customers;
| all_customers | customers_with_email |
|---|---|
| 212 | 209 |
That gap is Chapter 2's NULL lesson paying rent: NULL means unknown, and an aggregate honestly refuses to count what it doesn't know. When you want "how many rows", write COUNT(*) and mean it; when you want "how many rows actually have this value", count the column deliberately. Knowing which one you're asking for is the entire trick.
- "GROUP BY sorts the output." It groups; any order in the result is coincidence. If you want the counts sorted, ORDER BY still owns order — grouped queries take one like any other.
- "I can SELECT any column alongside COUNT." Only grouped columns or aggregates. A pile has many seats and many timestamps, the answer row has room for one value — the database refuses to guess which.
- "COUNT(email) counts rows." It counts non-NULL emails.
COUNT(*)counts rows. On any table with missing values the two disagree — by exactly the NULLs. - "GROUP BY drops the rows it groups." Every row lands in exactly one pile and every pile is counted. Nothing is filtered out — the rows are summarized, not discarded.
- Every dashboard number you've ever seen — sales per day, users per plan, tickets per screening — is an aggregate with a GROUP BY under it. This page is how reports are actually made.
- The iron rule is the #1 error message beginners hit in real SQL. Understanding why the database refuses turns the wall into a guardrail — you'll fix the query in seconds instead of fighting it.
Knowledge Check
What changes about the answer when GROUP BY enters a query?
- The rows come back automatically sorted by the grouped column value
- Each answer row now represents a group of rows, not a single row
- Rows that don't fit a group get filtered out
- The table is physically reorganized into groups
In the bookings-per-screening query, why does the database refuse SELECT screening_id, seat, COUNT(*) … GROUP BY screening_id;?
- A group holds many seat values, and the database won't guess which one to show
- Because seat is stored as a text column, and the COUNT function only works on numbers
- A grouped query may only SELECT two things
- Because seat doesn't exist in the bookings table
The customers table has 212 rows and 3 customers have no email. What do COUNT(*) and COUNT(email) return?
- 209 and 209 — missing rows aren't counted by either
- 212 and an error — COUNT can't handle NULLs
- 212 and 209 — the column count skips the NULLs
- 212 and 212 — the two forms always agree
With no GROUP BY at all, what does SELECT COUNT(*) FROM bookings; return?
- One row per screening, each with its count
- A single row: the total number of booking rows
- All 54 booking rows with a counter next to each
- An error — aggregates require a GROUP BY clause
You got correct