Cardinality — Learn 1:1, 1:N, and N:N Table Relationships from the Basics
BasicCardinality1:1 / 1:N / N:NJOINPostgreSQL Compatible5 questions
QUESTION 1
1:1 — INNER JOIN Users and Profiles to Confirm the Row Count Stays the Same
INNER JOIN1:1Row-count preservationCardinality
Background

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)
How to identify a 1:1 relationship: if the join key is unique in both tables (a PRIMARY KEY or UNIQUE constraint), the relationship is 1:1. Examples include one profile per user and one shipping address per order.
Problem

INNER JOIN the users and user_profiles tables on user_id, and retrieve the user's name and email address.

Tables used
▸ users
user_idname
1Tanaka
2Sato
3Yamada
▸ user_profiles
profile_iduser_idemail
11tanaka@example.com
22sato@example.com
33yamada@example.com

Note: user_profiles.user_id has a UNIQUE constraint, so each user has exactly one profile.

Expected Output
nameemail
Tanakatanaka@example.com
Satosato@example.com
Yamadayamada@example.com
Model Answer
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
*/
Explanation (table transitions & key points)
SELECT u.name, p.email FROM users AS u INNER JOIN user_profiles AS p ON u.user_id = p.user_id ORDER BY u.user_id;
LEGEND
Rows read / loaded
① FROM
FROM users AS uRead the users table (3 rows). user_id is the primary key, so its values are unique.
1 / 3
user_idname
1Tanaka
2Sato
3Yamada
All 3 rows read
LEARNING POINTS
CARDINALITY
1:1 — the join key is unique in both tables
The row count does not increase or decrease after the join
u = users   p = user_profiles   3 rows → 3 rows
Conditions for 1:1 — both keys are unique: users.user_id is unique as a PRIMARY KEY, and user_profiles.user_id is unique through its UNIQUE constraint. Since neither table has duplicate join-key values, each row matches exactly one row and no row expansion occurs. This is the essence of a 1:1 relationship.
The row-count formula — 1:1 is the simplest: for a 1:1 INNER JOIN, the row count is 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.
ANTI-PATTERNS
Assuming 1:1 without a UNIQUE constraint: without a UNIQUE constraint on user_profiles.user_id, one user could have multiple profiles. The JOIN would then expand rows (a 1:N relationship), which could cause aggregate values to be wrong. Make it a habit to check cardinality before joining.
1:1 data does not always need to be in a separate table: if name and email lived in the users table, no JOIN would be needed. Split 1:1 data only when there is a clear design reason, such as lazy-loading profile information, separating permissions, or different change frequencies.
Field Notes
Practical 1:1 examples include users ↔ user_settings (notification ON/OFF and other preferences), orders ↔ shipping_addresses (the shipping address for each order), and products ↔ product_details (separating product details). Because a 1:1 JOIN does not change the row count, it is the safest JOIN for using SUM or COUNT.
QUESTION 2
1:N — JOIN Departments and Employees to Understand Row Expansion
INNER JOIN1:NRow expansionCardinality
Background

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)
How row expansion affects aggregation:
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.
Problem

INNER JOIN the departments and employees tables, and retrieve each employee's name (emp_name) and department name (dept_name).

Tables used
▸ departments
dept_iddept_name
1Sales
2Engineering
3Human Resources
▸ employees
emp_idnamedept_id
1Tanaka1
2Sato1
3Yamada2
4Suzuki2
5Takahashi2

Note: Human Resources (dept_id=3) has no employees, so INNER JOIN excludes it.

Expected Output
emp_namedept_name
TanakaSales
SatoSales
YamadaEngineering
SuzukiEngineering
TakahashiEngineering
Model Answer
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
*/
Explanation (table transitions & key points)
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 ORDER BY d.dept_id, e.emp_id;
LEGEND
Rows read / loaded
① 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.
1 / 4
dept_iddept_name
1Sales
2Engineering
3Human Resources
All 3 rows read (the 1-side table)
LEARNING POINTS
CARDINALITY
1:N — each “1”-side row expands by the number of “N”-side matches
The row count follows the side with duplicate join-key values
d = departments (1 side)   e = employees (N side)   3 rows → 5 rows
How row expansion works: Sales (dept_id=1) matches two rows in employees, so Sales is copied into two rows in the JOIN result. Engineering expands to three rows in the same way. The row count after the JOIN is the number of matching rows on the “N” side; here, 2 + 3 = 5 rows.
How to identify 1:N: the join key is unique on one side (departments.dept_id is a PK) and duplicated on the other (employees.dept_id is shared by multiple employees). In SQL, remember: the PK side is “1” and the FK side is “N”.
ANTI-PATTERNS
Forgetting the 1:N expansion and summing a “1”-side value: if departments had a budget column and you calculated 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.
Overlooking departments with zero matches: Human Resources has no employees, so INNER JOIN does not include it. To display every department, use LEFT JOIN and count zero matches correctly with COUNT(e.emp_id).
Field Notes
1:N is a fundamental database-design pattern, and it covers most JOINs in real systems: users → orders (one user with multiple orders), categories → products (one category with multiple products), and posts → comments (one post with multiple comments). Always keep row expansion in mind and practice predicting “how many rows should this JOIN produce?” That habit is one of the best defenses against JOIN bugs.
QUESTION 3
1:N + GROUP BY — Aggregate Expanded JOIN Rows Back to the Original Row Count
INNER JOIN1:N + GROUP BYExpand → aggregateCOUNT / SUM
Background

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
Predicting row counts in the expand → aggregate pattern:
① JOIN: the number of rows on the “1” sidethe number of matching rows on the “N” side
② GROUP BY: the expanded row countthe number of groups (= the original number of rows on the “1” side)
Understanding this round trip is the foundation for writing correct aggregation queries.
Problem

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.

Tables used
▸ customers
customer_idname
1Tanaka
2Sato
3Yamada
▸ orders
order_idcustomer_idamount
113000
211500
325000
422000
52800
634000
Expected Output
nameorder_counttotal_amount
Sato37800
Tanaka24500
Yamada14000
Model Answer
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
*/
Explanation (table transitions & key points)
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 ORDER BY order_count DESC;
LEGEND
Rows read / loaded
① FROM
FROM customers AS cRead the customers table (3 rows). customer_id is the primary key on the “1” side.
1 / 5
customer_idname
1Tanaka
2Sato
3Yamada
All 3 rows read (the 1-side table)
LEARNING POINTS
ROW COUNT FLOW
Expand → aggregate — row-count change in 1:N JOIN + GROUP BY
Expand to the N-side row count in the JOIN → return to the original count with GROUP BY
3 original rows → 6 JOIN-expanded rows → 3 GROUP BY rows
Why expand → aggregate produces correct totals: each expanded row has a different record on the “N” side (order_id is unique in every row). GROUP BY collects these expanded rows by the “1”-side key, then COUNT and SUM calculate accurate values without duplicate records. 1:N + GROUP BY is the most fundamental SQL aggregation pattern.
Practice predicting row-count changes: before writing SQL, mentally trace “customers 3 rows × orders 6 rows → 6 rows after JOIN → 3 rows after GROUP BY.” This lets you validate the result. If GROUP BY returns an unexpected number of rows, suspect duplicate join keys or an incorrect cardinality assumption.
ANTI-PATTERNS
Forgetting GROUP BY leaves the expanded rows in the output: a query such as 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.
Omitting a non-aggregate column from GROUP BY causes an error: in PostgreSQL, writing only 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).
Field Notes
“Per-customer revenue totals,” “product counts by category,” and “monthly order counts” are all instances of the expand → aggregate pattern using 1:N JOIN + GROUP BY. In production, predicting how many rows the JOIN should produce helps prevent bugs before you inspect the result. Start by running SELECT COUNT(*) to confirm the row count after the JOIN.
QUESTION 4
N:N — Join Students and Courses Through a Junction Table to Understand Many-to-Many Relationships
INNER JOIN ×2N:NJunction tableCardinality
Background

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;
N:N = a combination of two 1:N relationships:
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.
Problem

Join students, enrollments (the junction table), and courses with two INNER JOINs, and list the course names each student is enrolled in.

Tables used
▸ students
student_idname
1Tanaka
2Sato
3Yamada
▸ enrollments (junction table)
enrollment_idstudent_idcourse_id
111
212
321
423
532
▸ courses
course_idcourse_name
1SQL Basics
2Python for Beginners
3Data Analysis
Expected Output
student_namecourse_name
TanakaSQL Basics
TanakaPython for Beginners
SatoSQL Basics
SatoData Analysis
YamadaPython for Beginners
Model Answer
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
  */
Explanation (table transitions & key points)
SELECT s.name AS student_name, c.course_name 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 ORDER BY s.student_id, c.course_id;
LEGEND
Rows read / loaded
① FROM
FROM students AS sRead the students table (3 rows). Next, reach courses through the junction table enrollments.
1 / 4
student_idname
1Tanaka
2Sato
3Yamada
All 3 rows read
LEARNING POINTS
CARDINALITY
N:N — connect two 1:N relationships through a junction table
The result row count equals the junction-table row count
s = students   e = enrollments (junction)   c = courses
The junction table determines the N:N row count: the first JOIN (students → enrollments) expands to the junction-table row count (5 rows) through 1:N. The second JOIN (enrollments → courses) is N:1, so the row count stays the same. Therefore, the final row count = the junction-table row count. Each junction-table row represents one relationship: “student X is enrolled in course Y.”
Use a UNIQUE constraint on the junction table: enrollments normally has a composite unique constraint such as UNIQUE(student_id, course_id). This prevents the same student from being enrolled in the same course twice and prevents unintended row inflation.
ANTI-PATTERNS
Trying to JOIN students directly to courses without the junction table: there is no join key for a direct JOIN between students and courses. If you force a CROSS JOIN, it generates 3×3=9 rows (every combination), not the actual enrollment relationships. N:N relationships require a junction table.
Representing N:N with comma-separated values (denormalization): storing a value such as 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.
Field Notes
Real-world N:N examples include users ↔ roles (one user can have multiple roles; junction table user_roles), products ↔ tags (one product can have multiple tags; junction table product_tags), and actors ↔ movies (cast relationships). A junction table can also hold relationship attributes such as enrolled_at or role_level, so it manages more than a simple link.
QUESTION 5
Fan Trap — The Duplicate-Aggregation Trap Caused by Joining Two 1:N Tables at Once
Derived tablesFan trapDuplicate aggregationJOIN bug
Background

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!
Why the Cartesian product occurs:
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.
Problem

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.

Tables used
▸ users
user_idname
1Tanaka
2Sato
▸ orders
order_iduser_idamount
113000
211500
325000
▸ reviews
review_iduser_idrating
115
213
324

Note: with the incorrect query, Tanaka's total_amount becomes 9000 instead of 4500 (it doubles because of the Cartesian product).

Expected Output
nametotal_amountavg_rating
Tanaka45004.0
Sato50004.0
Model Answer
-- ✓ 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
  */
Explanation (table transitions & key points)
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;
LEGEND
Grouping keys / aggregation targets
Group classification
① 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.
1 / 6
user_idamount
13000
11500
25000
orders: 3 rows → aggregated into 2 groups
LEARNING POINTS
FAN TRAP SOLUTION
Avoid the fan trap — aggregate first, convert to 1:1, then JOIN
Aggregate the two 1:N tables separately and JOIN them as 1:1 derived tables
u = users   o_agg = aggregated orders   r_agg = aggregated reviews
The essence of a fan trap — row inflation through a Cartesian product: when users is JOINed to orders (2 rows) and reviews (2 rows) at the same time, there is no join condition between orders and reviews, so a Cartesian product within the same user occurs. Tanaka expands to 2×2=4 rows and SUM(amount) doubles to 9000. Combining the Q2 insight that “1:N expands rows” shows why the two expansions multiply (N₁×N₂).
The key fix — convert 1:N to 1:1 by aggregating first: GROUP BY each 1:N table to make user_id unique in its derived table. JOIN those derived tables to users, and 1:1 × 1:1 prevents row inflation. Align the cardinality to 1:1 before joining.
ANTI-PATTERNS
Using DISTINCT to “hide” the Cartesian product: even if 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.
How to detect a fan trap: run 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.
Field Notes
Fan traps are one of the most common JOIN bugs in production. A typical case is “show each user's total sales and support-ticket count in one dashboard” → JOIN orders and tickets at the same time → sales are inflated. There are three common solutions: ① the derived-table approach in this question, ② a 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.