Cardinality (multiplicity) describes how many rows in one table correspond to each row in another table. It is essential for predicting how the row count changes before and after a JOIN.
The simplest relationship is 1:1. One row in table A corresponds to exactly one row in table B, so the row count does not increase or decrease after the join.
-- 1:1 example: 1 users row ↔ 1 user_profiles row SELECT u.name, p.email FROM users AS u INNER JOIN user_profiles AS p ON u.user_id = p.user_id; -- users: 3 rows → after JOIN: 3 rows (no change in row count)
INNER JOIN the users and user_profiles tables on user_id, and retrieve the user's name and email address.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| profile_id | user_id | |
|---|---|---|
| 1 | 1 | tanaka@example.com |
| 2 | 2 | sato@example.com |
| 3 | 3 | yamada@example.com |
Note: user_profiles.user_id has a UNIQUE constraint, so each user has exactly one profile.
| name | |
|---|---|
| Tanaka | tanaka@example.com |
| Sato | sato@example.com |
| Yamada | yamada@example.com |
SELECT u.name, p.email FROM users AS u INNER JOIN user_profiles AS p ON u.user_id = p.user_id -- user_id is unique in both tables ORDER BY u.user_id; /* Execution order: 1. FROM users AS u → Read rows 2. INNER JOIN user_profiles → Join (matching rows only) 3. SELECT u.name, p.email → Evaluate columns 4. ORDER BY u.user_id → Sort and output */
LEGEND
① FROM
FROM users AS uRead the users table (3 rows). user_id is the primary key, so its values are unique.| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
u = users p = user_profiles 3 rows → 3 rows
MIN(the number of matching rows in the left table, the number of matching rows in the right table). When every row matches, the row count does not change at all, so there is no duplicate-counting risk during aggregation. The next question (1:N) shows how the row count grows.1:N is the most common relationship. One row in table A corresponds to multiple rows in table B, so a JOIN expands each row on the “1” side by the number of matching rows on the “N” side.
-- 1:N example: 1 department → N employees SELECT d.dept_name, e.name FROM departments AS d INNER JOIN employees AS e ON d.dept_id = e.dept_id; -- departments: 3 rows → after JOIN: 5 rows (expanded by employee count)
in a 1:N JOIN, data on the “1” side (such as dept_name) is copied across N rows. Applying SUM or COUNT after this expansion can cause double or triple counting. Be aware of the expansion and choose the aggregation target correctly.
INNER JOIN the departments and employees tables, and retrieve each employee's name (emp_name) and department name (dept_name).
| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Engineering |
| 3 | Human Resources |
| emp_id | name | dept_id |
|---|---|---|
| 1 | Tanaka | 1 |
| 2 | Sato | 1 |
| 3 | Yamada | 2 |
| 4 | Suzuki | 2 |
| 5 | Takahashi | 2 |
Note: Human Resources (dept_id=3) has no employees, so INNER JOIN excludes it.
| emp_name | dept_name |
|---|---|
| Tanaka | Sales |
| Sato | Sales |
| Yamada | Engineering |
| Suzuki | Engineering |
| Takahashi | Engineering |
SELECT e.name AS emp_name, d.dept_name FROM departments AS d INNER JOIN employees AS e ON d.dept_id = e.dept_id -- 1 department → N employees (1:N expands rows) ORDER BY d.dept_id, e.emp_id; /* Execution order: 1. FROM departments AS d → Read rows 2. INNER JOIN employees AS e → Join (matching rows only) 3. SELECT e.name, d.dept_name → Evaluate columns 4. ORDER BY → Sort and output */
LEGEND
① FROM
FROM departments AS dRead the departments table (3 rows). dept_id is a unique primary key. Watch how rows on this “1” side change during the JOIN.| dept_id | dept_name |
|---|---|
| 1 | Sales |
| 2 | Engineering |
| 3 | Human Resources |
d = departments (1 side) e = employees (N side) 3 rows → 5 rows
SUM(d.budget) across the joined rows, Sales' budget would be counted twice and Engineering's three times. Aggregate an “N”-side column, or aggregate the “1” side before expanding it.LEFT JOIN and count zero matches correctly with COUNT(e.emp_id).This is the pattern for aggregating the row expansion caused by the 1:N JOIN from Q2. Group the N expanded rows by the original key on the “1” side, then aggregate them with COUNT or SUM.
-- Row-count flow: expand → aggregate customers(3 rows) → INNER JOIN orders → expand to 6 rows (1:N) → GROUP BY customer_id → aggregate to 3 rows
① JOIN:
the number of rows on the “1” side → the number of matching rows on the “N” side② GROUP BY:
the expanded row count → the number of groups (= the original number of rows on the “1” side)Understanding this round trip is the foundation for writing correct aggregation queries.
INNER JOIN customers and orders, and retrieve each customer's order count (order_count) and total amount (total_amount). Return the result in descending order of order count.
| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| order_id | customer_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
| 4 | 2 | 2000 |
| 5 | 2 | 800 |
| 6 | 3 | 4000 |
| name | order_count | total_amount |
|---|---|---|
| Sato | 3 | 7800 |
| Tanaka | 2 | 4500 |
| Yamada | 1 | 4000 |
SELECT c.name, COUNT(o.order_id) AS order_count, SUM(o.amount) AS total_amount FROM customers AS c INNER JOIN orders AS o ON c.customer_id = o.customer_id GROUP BY c.customer_id, c.name -- Aggregate expanded rows by the original customer ORDER BY order_count DESC; /* Execution order: 1. FROM customers AS c → Read rows 2. INNER JOIN orders AS o → Join (matching rows only) 3. GROUP BY c.customer_id → Create groups 4. Aggregate functions → Evaluate COUNT and SUM 5. SELECT → Evaluate columns 6. ORDER BY order_count → Sort and output */
LEGEND
① FROM
FROM customers AS cRead the customers table (3 rows). customer_id is the primary key on the “1” side.| customer_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
3 original rows → 6 JOIN-expanded rows → 3 GROUP BY rows
SELECT c.name, o.amount FROM customers c JOIN orders o ... without GROUP BY returns Sato on three separate rows. Always write GROUP BY when aggregating after a 1:N JOIN.SELECT c.name ... GROUP BY c.customer_id makes c.name an error. Every column in SELECT that is not inside an aggregate function must also appear in GROUP BY (except where functional dependency through a primary key applies).SELECT COUNT(*) to confirm the row count after the JOIN.N:N (many-to-many) means that one student attends multiple courses, while one course has multiple students. In an RDBMS, this relationship is represented with a junction table.
-- N:N structure: students ←(1:N)→ enrollments ←(N:1)→ courses FROM students AS s INNER JOIN enrollments AS e ON s.student_id = e.student_id INNER JOIN courses AS c ON e.course_id = c.course_id;
the junction table (enrollments) has foreign keys to both students and courses.
students →(1:N)→ enrollments and courses →(1:N)→ enrollments form the two 1:N relationships. The row count after the JOIN matches the number of rows in the junction table.Join students, enrollments (the junction table), and courses with two INNER JOINs, and list the course names each student is enrolled in.
| student_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
| enrollment_id | student_id | course_id |
|---|---|---|
| 1 | 1 | 1 |
| 2 | 1 | 2 |
| 3 | 2 | 1 |
| 4 | 2 | 3 |
| 5 | 3 | 2 |
| course_id | course_name |
|---|---|
| 1 | SQL Basics |
| 2 | Python for Beginners |
| 3 | Data Analysis |
| student_name | course_name |
|---|---|
| Tanaka | SQL Basics |
| Tanaka | Python for Beginners |
| Sato | SQL Basics |
| Sato | Data Analysis |
| Yamada | Python for Beginners |
SELECT s.name AS student_name, c.course_name FROM students AS s INNER JOIN enrollments AS e -- First: students →(1:N)→ enrollments ON s.student_id = e.student_id INNER JOIN courses AS c -- Second: enrollments →(N:1)→ courses ON e.course_id = c.course_id ORDER BY s.student_id, c.course_id; /* Execution order: 1. FROM students AS s → Read students 2. INNER JOIN enrollments AS e → Join (expand through 1:N) 3. INNER JOIN courses AS c → Join (N:1) 4. SELECT s.name, c.course_name → Project two columns 5. ORDER BY → Sort by student_id and course_id */
LEGEND
① FROM
FROM students AS sRead the students table (3 rows). Next, reach courses through the junction table enrollments.| student_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| 3 | Yamada |
s = students e = enrollments (junction) c = courses
UNIQUE(student_id, course_id). This prevents the same student from being enrolled in the same course twice and prevents unintended row inflation.course_ids = '1,2' in students makes JOINs, searches, and aggregation difficult. Normalization with a junction table is the correct RDB representation of N:N.A fan trap occurs when two 1:N tables are JOINed to the same table at the same time, causing unintended row multiplication (a Cartesian product within each user). It is one of the most common JOIN bugs and brings together the cardinality concepts from this set.
-- [Warning] Wrong: JOIN two 1:N tables at once → Cartesian product FROM users AS u INNER JOIN orders AS o ON u.user_id = o.user_id -- 1:N (1) INNER JOIN reviews AS r ON u.user_id = r.user_id -- 1:N (2) -- Tanaka: 2 orders × 2 reviews = 4 rows — inflated!
orders and reviews have no direct join key, so Tanaka's 2 orders and 2 reviews generate every possible combination (2×2=4 rows). If you calculate
SUM(o.amount) in this state, each amount is counted once for every review row, producing an incorrect aggregate.Retrieve each user's total order amount (total_amount) and average review rating (avg_rating). To avoid the fan trap, solve it with the correct approach: aggregate each 1:N table separately, then JOIN the aggregates.
| user_id | name |
|---|---|
| 1 | Tanaka |
| 2 | Sato |
| order_id | user_id | amount |
|---|---|---|
| 1 | 1 | 3000 |
| 2 | 1 | 1500 |
| 3 | 2 | 5000 |
| review_id | user_id | rating |
|---|---|---|
| 1 | 1 | 5 |
| 2 | 1 | 3 |
| 3 | 2 | 4 |
Note: with the incorrect query, Tanaka's total_amount becomes 9000 instead of 4500 (it doubles because of the Cartesian product).
| name | total_amount | avg_rating |
|---|---|---|
| Tanaka | 4500 | 4.0 |
| Sato | 5000 | 4.0 |
-- ✓ Correct approach: aggregate each 1:N separately → safely JOIN as 1:1 SELECT u.name, o_agg.total_amount, r_agg.avg_rating FROM users AS u INNER JOIN ( SELECT user_id, SUM(amount) AS total_amount FROM orders GROUP BY user_id ) AS o_agg ON u.user_id = o_agg.user_id INNER JOIN ( SELECT user_id, ROUND(AVG(rating), 1) AS avg_rating FROM reviews GROUP BY user_id ) AS r_agg ON u.user_id = r_agg.user_id; /* Execution order: 1. Subquery 1 → Aggregate orders (o_agg) 2. Subquery 2 → Aggregate reviews (r_agg) 3. FROM users AS u → Read users 4. INNER JOIN o_agg → Join on user_id 5. INNER JOIN r_agg → Join on user_id 6. SELECT → Output the result */
LEGEND
① Derived table 1: aggregate orders
SELECT user_id, SUM(amount) FROM orders GROUP BY user_idFirst, aggregate orders by user_id to produce the derived table o_agg. Because user_id becomes unique, the later JOIN is 1:1.| user_id | amount |
|---|---|
| 1 | 3000 |
| 1 | 1500 |
| 2 | 5000 |
u = users o_agg = aggregated orders r_agg = aggregated reviews
SELECT DISTINCT removes duplicate-looking rows, aggregate functions such as SUM and AVG still contain the wrong values created by the Cartesian expansion. DISTINCT reduces visible rows; it does not fix duplicate counting inside aggregates.SELECT COUNT(*) after the JOIN and suspect a fan trap when the row count is larger than expected. It is especially likely when two or more 1:N tables are JOINed to the same table. An unusually large total is another typical warning sign.WITH clause (CTE) for readability, and ③ window functions. The core principle is the same in every case: aggregate first and align the cardinality to 1:1.