LEARN TO CODE | SQL | 03 COMBINING AND SUMMARISING DATA
03.3 GROUP BY and HAVING
Create one summary per group and distinguish filtering source rows from filtering completed group summaries.
01 | ONE TOTAL IS NOT ALWAYS ENOUGH
The organiser wants capacity by category, not one number for the whole database. GROUP BY divides the input into groups so each group can produce its own summary.
Decide what one result row should mean: one category, one club or one year level. That decision guides the grouping columns.
Use this section’s three-table practice database. It is a fresh dataset, separate from Sections 01 and 02. All examples use SQLite. A membership score is an optional fictional activity result from 0 to 100; NULL means not yet recorded, not zero.
Set up and run the examples in VS Code
Create
sql_summaries.py
, paste the complete runner below and click Run Python File with your Python interpreter selected. Replace only the SQL inside the query string for each exercise. Each run rebuilds the same in-memory database.
Results appear in the terminal; Python displays SQL NULL as None. The simplified schema focuses on retrieval rather than enforcing every range rule from earlier lessons.
import sqlite3
connection = sqlite3.connect(":memory:")
setup_sql = """
PRAGMA foreign_keys = ON;
CREATE TABLE Students (
student_id INTEGER PRIMARY KEY,
first_name TEXT NOT NULL,
year_level INTEGER NOT NULL
);
CREATE TABLE Clubs (
club_id INTEGER PRIMARY KEY,
club_name TEXT NOT NULL,
category TEXT NOT NULL,
capacity INTEGER NOT NULL,
fee_cents INTEGER NOT NULL
);
CREATE TABLE Memberships (
student_id INTEGER NOT NULL,
club_id INTEGER NOT NULL,
role TEXT NOT NULL,
score INTEGER,
PRIMARY KEY (student_id, club_id),
FOREIGN KEY (student_id) REFERENCES Students(student_id),
FOREIGN KEY (club_id) REFERENCES Clubs(club_id)
);
INSERT INTO Students VALUES
(101, 'Maya', 10), (102, 'Leo', 11), (103, 'Amina', 10),
(104, 'Noah', 12), (105, 'Maya', 11);
INSERT INTO Clubs VALUES
(1, 'Robotics', 'Technology', 12, 1500),
(2, 'Game Design', 'Technology', 16, 0),
(3, 'Art', 'Creative', 12, 800),
(4, 'Film', 'Creative', 20, 1000),
(5, 'Chess', 'Strategy', 16, 0);
INSERT INTO Memberships VALUES
(101, 1, 'member', 95), (101, 2, 'leader', 85),
(102, 1, 'member', NULL), (103, 3, 'member', 70),
(103, 4, 'leader', 90), (104, 2, 'member', 80);
"""
query = """
SELECT club_id, club_name FROM Clubs ORDER BY club_id;
"""
try:
connection.executescript(setup_sql)
cursor = connection.execute(query)
print(" | ".join(column[0] for column in cursor.description))
for row in cursor.fetchall():
print(row)
except sqlite3.Error as error:
print("Database error:", error)
finally:
connection.close()
02 | REFERENCE DATA
| student_id | first_name | year_level |
|---|---|---|
| 101 | Maya | 10 |
| 102 | Leo | 11 |
| 103 | Amina | 10 |
| 104 | Noah | 12 |
| 105 | Maya | 11 |
| club_id | club_name | category | capacity | fee_cents |
|---|---|---|---|---|
| 1 | Robotics | Technology | 12 | 1500 |
| 2 | Game Design | Technology | 16 | 0 |
| 3 | Art | Creative | 12 | 800 |
| 4 | Film | Creative | 20 | 1000 |
| 5 | Chess | Strategy | 16 | 0 |
| student_id | club_id | role | score |
|---|---|---|---|
| 101 | 1 | member | 95 |
| 101 | 2 | leader | 85 |
| 102 | 1 | member | NULL |
| 103 | 3 | member | 70 |
| 103 | 4 | leader | 90 |
| 104 | 2 | member | 80 |
03 | BUILD A GROUPED RESULT
SELECT category, COUNT(*) AS club_count,
SUM(capacity) AS total_capacity
FROM Clubs
GROUP BY category
ORDER BY category;
| category | club_count | total_capacity |
|---|---|---|
| Creative | 2 | 32 |
| Strategy | 1 | 16 |
| Technology | 2 | 28 |
Creative contains Art and Film, so it has two clubs and capacity 32. Technology contains Robotics and Game Design, giving two clubs and capacity 28. Strategy has Chess alone, giving 16.
GROUP BY is not an ordering promise. ORDER BY explicitly controls the display order.
04 | WALK THROUGH THE GROUPS
| Group | Source capacities | Summary |
|---|---|---|
| Creative | 12, 20 | 2 clubs; sum 32 |
| Strategy | 16 | 1 club; sum 16 |
| Technology | 12, 16 | 2 clubs; sum 28 |
Grouping does not combine the stored records permanently. It builds result rows summarising the selected input.
Every selected column should be a grouping column or an aggregate in the clear, portable queries we write here. A bare club_name beside grouped category totals does not identify all names in the group.
05 | WHERE FILTERS THE INPUT ROWS
SELECT category, COUNT(*) AS club_count,
SUM(capacity) AS total_capacity
FROM Clubs
WHERE fee_cents = 0
GROUP BY category
ORDER BY category;
| category | club_count | total_capacity |
|---|---|---|
| Strategy | 1 | 16 |
| Technology | 1 | 16 |
Only Game Design and Chess enter this grouping. The Technology summary therefore counts one free club, not both Technology clubs.
Ask whether your condition concerns individual records or a group summary before choosing WHERE or HAVING.
06 | HAVING FILTERS THE GROUPS
SELECT category, COUNT(*) AS club_count,
SUM(capacity) AS total_capacity
FROM Clubs
GROUP BY category
HAVING COUNT(*) >= 2
ORDER BY category;
| category | club_count | total_capacity |
|---|---|---|
| Creative | 2 | 32 |
| Technology | 2 | 28 |
All club rows enter the groups, then HAVING keeps groups containing at least two clubs. Creative and Technology remain; Strategy is removed.
WHERE COUNT(*) >= 2 does not express this task: the aggregate does not describe an individual source row at that stage.
07 | COMBINE WHERE AND HAVING
SELECT category, COUNT(*) AS paid_clubs,
SUM(capacity) AS paid_capacity
FROM Clubs
WHERE fee_cents > 0
GROUP BY category
HAVING SUM(capacity) >= 20
ORDER BY category;
| category | paid_clubs | paid_capacity |
|---|---|---|
| Creative | 2 | 32 |
WHERE removes free clubs first. Of the remaining category summaries, only Creative reaches capacity 20 or more. Technology’s paid subset contains Robotics alone, capacity 12.
A helpful logical reading is FROM/JOIN → WHERE → GROUP BY → HAVING → result expressions → ORDER BY → LIMIT. This is a reasoning aid, not a physical execution guarantee.
08 | GROUP JOINED MEMBERSHIPS
SELECT c.club_id, c.club_name, COUNT(*) AS members
FROM Clubs AS c
INNER JOIN Memberships AS m ON c.club_id = m.club_id
GROUP BY c.club_id, c.club_name
ORDER BY c.club_id;
| club_id | club_name | members |
|---|---|---|
| 1 | Robotics | 2 |
| 2 | Game Design | 2 |
| 3 | Art | 1 |
| 4 | Film | 1 |
One result row now means one club with at least one membership. Grouping by ID and name keeps different clubs separate even if descriptive names could repeat.
Chess is absent because INNER JOIN never created an input row for it. GROUP BY cannot restore an input that was removed.
09 | INCLUDE ZERO-MEMBER CLUBS CORRECTLY
SELECT c.club_id, c.club_name,
COUNT(m.student_id) AS member_count,
COUNT(m.score) AS recorded_scores,
AVG(m.score) AS average_score
FROM Clubs AS c
LEFT JOIN Memberships AS m ON c.club_id = m.club_id
GROUP BY c.club_id, c.club_name
ORDER BY c.club_id;
| club_id | club_name | member_count | recorded_scores | average_score |
|---|---|---|---|---|
| 1 | Robotics | 2 | 1 | 95.0 |
| 2 | Game Design | 2 | 2 | 82.5 |
| 3 | Art | 1 | 1 | 70.0 |
| 4 | Film | 1 | 1 | 90.0 |
| 5 | Chess | 0 | 0 | NULL |
Chess gets one unmatched placeholder row, but m.student_id is NULL there, so COUNT(m.student_id) correctly gives zero. COUNT(*) would count that placeholder as one.
Chess’s average is NULL because it has no recorded scores. Robotics has two members but only one recorded score, giving an average of 95 for recorded scores. Do not read that as two equal scores.
10 | RETAIN ONLY GROUPS NEEDING ATTENTION
SELECT c.club_id, c.club_name, COUNT(m.student_id) AS members
FROM Clubs AS c
LEFT JOIN Memberships AS m ON c.club_id = m.club_id
GROUP BY c.club_id, c.club_name
HAVING COUNT(m.student_id) < 2
ORDER BY c.club_id;
| club_id | club_name | members |
|---|---|---|
| 3 | Art | 1 |
| 4 | Film | 1 |
| 5 | Chess | 0 |
Art, Film and Chess have fewer than two members in this dataset. The LEFT JOIN and non-NULL key count are both necessary to include zero-member clubs.
11 | INDEPENDENT PRACTICE
TRY IT YOURSELF
Show students with at least two memberships, including their IDs and names.
Reveal query and result
SELECT s.student_id, s.first_name, COUNT(m.club_id) AS memberships
FROM Students AS s
INNER JOIN Memberships AS m ON s.student_id = m.student_id
GROUP BY s.student_id, s.first_name
HAVING COUNT(m.club_id) >= 2
ORDER BY s.student_id;
| student_id | first_name | memberships |
|---|---|---|
| 101 | Maya | 2 |
| 103 | Amina | 2 |
Maya 101 and Amina each have two. Grouping only by first_name could combine unrelated people.
TRY IT YOURSELF
Show categories whose total club capacity exceeds 30.
Reveal query and result
SELECT category, SUM(capacity) AS total_capacity
FROM Clubs
GROUP BY category
HAVING SUM(capacity) > 30
ORDER BY category;
| category | total_capacity |
|---|---|
| Creative | 32 |
Creative qualifies with 32; Technology has 28 and Strategy 16.
KEEP LEARNING
Previous page 03.4 Simple subqueries
References: SQLite joins and grouping · Aggregate functions · Subquery expressions