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

Expected result on the Section 03 dataset
student_id first_name year_level
101 Maya 10
102 Leo 11
103 Amina 10
104 Noah 12
105 Maya 11
Expected result on the Section 03 dataset
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
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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;
Expected result on the Section 03 dataset
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