LEARN TO CODE | SQL | 03 COMBINING AND SUMMARISING DATA
03 Combining and summarising data: challenges
Combine, count and summarise related records, then build a report that handles missing matches and missing values correctly.
START HERE | INDEPENDENT PRACTICE
Attempt each task before opening its hint or solution. Predict the number of result rows and explain any NULL values before running the query.
Use this section’s dataset and runner below, not the earlier datasets. Equivalent queries are valid if they satisfy the same requirements and ordering. Tick completion after checking and explaining the result; ticks last while this page remains open.
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()
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 |
CHALLENGE 01 | A READABLE MEMBERSHIP LIST
Display student IDs, names and club names for all actual memberships.
Need a hint?
Join through Memberships, not directly between unrelated IDs.
Reveal query and result
SELECT s.student_id, s.first_name, c.club_name
FROM Students AS s
INNER JOIN Memberships AS m ON s.student_id = m.student_id
INNER JOIN Clubs AS c ON m.club_id = c.club_id
ORDER BY s.student_id, c.club_id;
| student_id | first_name | club_name |
|---|---|---|
| 101 | Maya | Robotics |
| 101 | Maya | Game Design |
| 102 | Leo | Robotics |
| 103 | Amina | Art |
| 103 | Amina | Film |
| 104 | Noah | Game Design |
There are six result rows. Repeated students reflect multiple memberships.
CHALLENGE 02 | NO MEMBERSHIP YET
Find students with no membership.
Need a hint?
Preserve Students and test a required right-side key for NULL.
Reveal query and result
SELECT s.student_id, s.first_name
FROM Students AS s
LEFT JOIN Memberships AS m ON s.student_id = m.student_id
WHERE m.student_id IS NULL
ORDER BY s.student_id;
| student_id | first_name |
|---|---|
| 105 | Maya |
Student 105 qualifies. A NULL score would not prove an absent membership.
CHALLENGE 03 | THE SCORE DENOMINATOR
Return membership count, recorded-score count and average recorded score.
Need a hint?
COUNT(*) and COUNT(score) count different things.
Reveal query and result
SELECT COUNT(*) AS memberships, COUNT(score) AS recorded_scores,
AVG(score) AS average_score
FROM Memberships;
| memberships | recorded_scores | average_score |
|---|---|---|
| 6 | 5 | 84.0 |
The result is 6, 5 and 84. Missing is not zero.
CHALLENGE 04 | THE CLUB SUMMARY
Return count, total capacity, minimum capacity and maximum capacity across Clubs.
Need a hint?
Aggregate the table that represents one club per row.
Reveal query and result
SELECT COUNT(*) AS clubs, SUM(capacity) AS total_capacity,
MIN(capacity) AS minimum_capacity, MAX(capacity) AS maximum_capacity
FROM Clubs;
| clubs | total_capacity | minimum_capacity | maximum_capacity |
|---|---|---|---|
| 5 | 76 | 12 | 20 |
The result is 5, 76, 12 and 20.
CHALLENGE 05 | CATEGORY TOTALS
Show categories with at least two clubs and their combined capacity.
Need a hint?
Filter group counts with HAVING.
Reveal query and result
SELECT category, COUNT(*) AS clubs, SUM(capacity) AS total_capacity
FROM Clubs
GROUP BY category
HAVING COUNT(*) >= 2
ORDER BY category;
| category | clubs | total_capacity |
|---|---|---|
| Creative | 2 | 32 |
| Technology | 2 | 28 |
Creative has capacity 32 and Technology 28.
CHALLENGE 06 | ZERO COUNTS MATTER
Show every club with its membership count, including empty clubs.
Need a hint?
LEFT JOIN then count a required membership key.
Reveal query and result
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
ORDER BY c.club_id;
| club_id | club_name | members |
|---|---|---|
| 1 | Robotics | 2 |
| 2 | Game Design | 2 |
| 3 | Art | 1 |
| 4 | Film | 1 |
| 5 | Chess | 0 |
Chess must have zero, not one. COUNT(*) would count its placeholder row.
CHALLENGE 07 | INPUT OR GROUP FILTER?
Among paid clubs only, show categories whose combined capacity is at least 20.
Need a hint?
WHERE applies to individual fees; HAVING applies to the capacity total.
Reveal query and result
SELECT category, SUM(capacity) AS paid_capacity
FROM Clubs
WHERE fee_cents > 0
GROUP BY category
HAVING SUM(capacity) >= 20
ORDER BY category;
| category | paid_capacity |
|---|---|
| Creative | 32 |
Only Creative remains. Free clubs never enter these groups.
CHALLENGE 08 | ABOVE THE AVERAGE
List clubs with above-average capacity, largest first, breaking ties by ID.
Need a hint?
Use one aggregate value in the outer comparison.
Reveal query and result
SELECT club_name, capacity
FROM Clubs
WHERE capacity > (SELECT AVG(capacity) FROM Clubs)
ORDER BY capacity DESC, club_id;
| club_name | capacity |
|---|---|
| Film | 20 |
| Game Design | 16 |
| Chess | 16 |
The threshold is 15.2. Film, Game Design and Chess qualify.
CHALLENGE 09 | A LIST OF LEADERS
Show each student who leads at least one club, once per student.
Need a hint?
An IN subquery can return several IDs.
Reveal query and result
SELECT student_id, first_name
FROM Students
WHERE student_id IN (SELECT student_id FROM Memberships WHERE role = 'leader')
ORDER BY student_id;
| student_id | first_name |
|---|---|
| 101 | Maya |
| 103 | Amina |
Maya 101 and Amina qualify. The outer Students table provides one row per student.
CHALLENGE 10 | DEBUG THE EMPTY CLUB
Repair a report that uses INNER JOIN and COUNT(*) but must include Chess with a zero member count.
Need a hint?
Both the join and what you count matter.
Reveal query and result
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
ORDER BY c.club_id;
| club_id | club_name | members |
|---|---|---|
| 1 | Robotics | 2 |
| 2 | Game Design | 2 |
| 3 | Art | 1 |
| 4 | Film | 1 |
| 5 | Chess | 0 |
Changing only COUNT does not restore Chess; changing only JOIN makes COUNT(*) misleading.
CHALLENGE 11 | BUSY STUDENTS
Show student IDs and names with at least two memberships.
Need a hint?
Group by identity, not name alone.
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 have two memberships each. Maya 105 is a different person.
CHALLENGE 12 | CAPSTONE: CLUB HEALTH REPORT
For every club with fewer than two memberships, show ID, name, capacity, member count, remaining planned places and average recorded score. Include empty clubs, order by member count then club ID, and explain every NULL.
Need a hint?
Use LEFT JOIN, GROUP BY, HAVING and capacity minus the count.
Reveal query and result
SELECT c.club_id, c.club_name, c.capacity,
COUNT(m.student_id) AS members,
c.capacity - COUNT(m.student_id) AS remaining_places,
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, c.capacity
HAVING COUNT(m.student_id) < 2
ORDER BY members, c.club_id;
| club_id | club_name | capacity | members | remaining_places | average_score |
|---|---|---|---|---|---|
| 5 | Chess | 16 | 0 | 16 | NULL |
| 3 | Art | 12 | 1 | 11 | 70.0 |
| 4 | Film | 20 | 1 | 19 | 90.0 |
Chess has zero members, 16 remaining planned places and NULL average. Art has one member, 11 places and average 70; Film has one member, 19 places and average 90. This is a report, not enforcement of a capacity rule.
STRETCH | EXPLAIN THE TRAPS
Explain why each statement is unsafe: “COUNT(*) always counts students”; “a NULL score proves there is no membership”; “SUM(DISTINCT capacity) fixes every repeated join total”; “= can safely compare a value with any multi-row subquery”; and “GROUP BY automatically sorts the output”.
Reveal reasoning
COUNT(*) counts input rows. Real memberships may have NULL scores. Different clubs can share a capacity, so DISTINCT values lose legitimate contributions. Scalar subqueries must return the intended single value; use IN for a list. ORDER BY controls output ordering.
FINISH | TEST A CHANGED DATASET
As a thought experiment, add one membership for student 105 in Chess. Predict changes to the non-member report, zero-club report and global membership count. Then try the insert in setup_sql and rerun relevant queries.
Reveal predictions
No students remain without memberships; no club has zero members; the membership count rises to seven. If the new score is NULL, recorded-score count remains five and average recorded score remains 84. Restore the original dataset before comparing with the solutions on this page.
Record one join mistake and one summary mistake you can now explain. Next we will change stored data deliberately using INSERT, UPDATE and DELETE.
KEEP LEARNING
Previous page 04.1 INSERT, UPDATE and DELETE
References: SQLite joins and grouping · Aggregate functions · Subquery expressions