Warming up the neural circuits...
By the end of this chapter you will:
The real world is relational. A user has orders. An order has items. An item belongs to a product. A product belongs to a category. If your database can't model these connections cleanly, your application code becomes a tangle of manual lookups, duplicate data, and impossible-to-debug consistency bugs.
Relationships are the hardest part of database design, full stop. Get them right and queries are clean, fast, and correct. Get them wrong and you're writing 7 queries where 1 should suffice, or worse — returning wrong data that nobody catches because "it happens intermittently."
users ↔ user_profiles)The most common relationship. The "many" side holds the .
-- One user → many orders
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id), -- FK on the "many" side
total NUMERIC(10,
Rule: The FK always goes on the "many" side. orders.user_id references users.id. Never the other way — you can't predict how many order_ids to add to the users table.
Rare but useful. The FK goes on either table — but you add a UNIQUE constraint so each row references only one parent row.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL
);
CREATE TABLE user_profiles (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL UNIQUE REFERENCES users(id), -- UNIQUE makes it 1:1
bio TEXT,
avatar_url VARCHAR(500)
When to use 1:1:
When NOT to use 1:1: If the columns are always accessed together, keep them in one table. A 1:1 JOIN is overhead you don't need.
Requires a junction table (also called a join table, bridge table, or associative entity). Students ↔ Courses:
CREATE TABLE students (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL
);
CREATE TABLE courses (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL
);
-- Junction table
CREATE TABLE enrollments (
student_id BIGINT
An enrollment isn't just a connection — it's an entity. It has its own attributes: enrolled_at, grade, completed. The junction table is a real table with real columns. This is where most beginners stop short — they add a bare (student_id, course_id) and miss the opportunity to model the relationship itself as data.
SQL has 5 JOIN types. Memorize what each one returns:
-- Sample data:
-- users: (1, Alice), (2, Bob), (3, Carol)
-- orders: (user_id=1, total=50), (user_id=1, total=75), (user_id=NULL, total=10)| JOIN Type | Returns | Bob appears? | Carol appears? | order with user_id=NULL? |
|---|---|---|---|---|
INNER JOIN | Only rows with a match in both tables | ❌ (no orders) | ❌ (no orders) | ❌ |
LEFT JOIN | All rows from left table + matching rows from right | ✅ (null orders) | ✅ (null orders) | ❌ |
RIGHT JOIN | All rows from right table + matching rows from left | ❌ (no orders) | ❌ (no orders) | ✅ (null user) |
FULL OUTER JOIN | All rows from both tables, matching where possible | ✅ | ✅ | ✅ |
CROSS JOIN | Cartesian product — every row from left × every row from right | ✅ (3×3=9 rows) | ✅ | ✅ |
When to use each:
INNER JOIN — default. "Show me users who have placed orders."LEFT JOIN — 90% of your JOINs. "Show me all users, with their orders if they have any."RIGHT JOIN — almost never. Rewrite as a LEFT JOIN (swap table order). More readable.FULL OUTER JOIN — data integrity checks. "Find orders with invalid user_ids AND users with no orders."CROSS JOIN — generate test data or all combinations. Rare in production queries.Most developers never use RIGHT JOIN. If you find yourself writing one, swap the table order and use LEFT JOIN instead. Your future self (and your teammates) will understand it immediately:
-- These are identical:
SELECT * FROM orders RIGHT JOIN users ON orders.user_id = users.id;
SELECT * FROM users LEFT JOIN orders
The single most common backend performance bug. You've probably written it without knowing.
// ❌ N+1 — 1 query for users + N queries for their orders
const users = await db.query('SELECT * FROM users'); // 1 query
for (const user of users) {
user.orders = await db.query(
Where N+1 hides:
How to spot N+1: Look for database queries inside loops. If you see for (const x of array) { await query(...) }, you have an N+1 problem. The fix is always the same: batch the query — JOIN, WHERE IN, or a data loader.
// ✅ Fix with WHERE IN
const users = await db.query('SELECT * FROM users');
const userIds = users.map(u => u.id);
const orders = await db.
In MongoDB, you model relationships differently:
| Relationship | SQL | MongoDB — Embed | MongoDB — Reference |
|---|---|---|---|
| 1:1 | FK + UNIQUE | Embed the profile inside the user document | Separate collection with user_id |
| 1:N (small, bounded) | FK | Embed the array inside the parent | Separate collection |
| 1:N (large, unbounded) | FK | ❌ Don't embed | Separate collection + |
| M:N (small) | Junction table | Embed arrays on both sides (maintain consistency yourself) | Junction collection |
| M:N (large) | Junction table | ❌ Don't embed | Junction collection + indexes |
Instagram's core data model is a masterclass in relationship design:
user_id. Classic FK on the "many" side.follows table with follower_id and followee_id. Both reference users.id. This is a self-referential M:N — the same table on both sides of the junction.post_hashtags junction table. Instagram denormalized popular hashtag counts onto the hashtag table itself (hashtags.post_count) because displaying trending hashtags was a hot query.likes table with user_id and post_id. Instagram shards this table by post_id because likes are always fetched in the context of a post ("who liked post X?"), rarely in the context of a user ("what did user X like?").Instagram's lesson: M:N junction tables become real entities over time. A likes table starts as (user_id, post_id) but evolves to include created_at, then becomes the backbone of the recommendation algorithm.
| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| FK on the wrong side of a 1:N | users (id, name, order_id) — now each user can only have one order. Broken model. | FK always on the "many" side: orders.user_id REFERENCES users(id) |
| No index on FK columns | Every JOIN triggers a full table scan on the child table | CREATE INDEX ON orders(user_id); — always |
| N+1 queries from lazy loading | 100 users × 1 extra query = 101 queries. ORM hides the loop. | Use eager loading (include in Prisma, with in Drizzle) or batch with WHERE IN |
Using RIGHT JOIN in application code | Unreadable, unexpected by teammates, harder to debug | Rewrite as LEFT JOIN |
| M:N without a junction table | Storing comma-separated IDs: students.course_ids = "1,3,7" — unindexable, unjoinable, un-constrainable | Always use a junction table for M:N |
Forgetting ON DELETE behavior | Deleting a user silently orphans their orders (FK constraint blocks it, or CASCADE deletes orders by surprise) | Choose explicitly: ON DELETE CASCADE (delete orders too), , or (block deletion) |
likes table has trillions of rows. When junction tables grow, partition them (by post_id range or by date) and consider sharding.CASCADE deletes on large tables. Deleting a user with 500,000 orders via ON DELETE CASCADE locks every one of those order rows in a single . Use a background job for bulk cleanup instead.json_agg and array_agg are your friends. PostgreSQL can return nested JSON directly, avoiding N+1 in APIs. Use with LEFT JOIN + GROUP BY for efficient nested responses.ANALYZE regularly.ANALYZE).WHERE IN with large arrays is slow. More than ~1000 values in ANY($1) can degrade. For bulk lookups, use a temporary table or split into batches.order.user_id = 99999 if user 99999 doesn't exist. This is a security feature, not just a data feature.order_items referencing deleted orders, which then show up in reports with null data — misleading, hard to detect, and sometimes a compliance violation.authors, books, and a junction table book_authors (a book can have multiple authors). Insert data. Write queries to get all books by a given author and all authors of a given book.LEFT JOIN query that returns all users and their orders (if any). Then write an INNER JOIN query. Explain the difference in the result set.manager_id that references the same employees table). Write a recursive CTE that returns the entire management chain for a given employee.ltree extension or a polymorphic junction table. Explain the tradeoffs between a single tags junction table vs per-entity junction tables (post_tags, product_tags).What is a foreign key? A column (or set of columns) in one table that references the primary key of another table, enforcing referential integrity — you can't insert a child row referencing a parent that doesn't exist.
What's the difference between LEFT JOIN and INNER JOIN? INNER JOIN returns only rows where there's a match in both tables. LEFT JOIN returns all rows from the left table plus matching rows from the right table, filling unmatched columns with NULL.
What is the N+1 query problem? Executing 1 query to fetch N records, then N additional queries (one per record) to fetch related data — resulting in N+1 total queries instead of 1 well-constructed JOIN or batch query.
How would you model a M:N relationship with additional attributes on the relationship? Use a junction table with columns beyond just the two foreign keys. Example: enrollments(student_id, course_id, enrolled_at, grade, completed). The junction becomes a first-class entity.
When would you denormalize a relationship, embedding child data into the parent row? When the child data is always accessed with the parent, rarely changes independently, and is bounded in size. Example: shipping address on an order — once the order ships, the address should never change. Storing it on the order is a snapshot of the truth at the time of the order. Also common in event sourcing: store the full with the event, not just a foreign key.
How do you handle cascading deletes in a large production database? Avoid ON DELETE CASCADE for large child tables — it runs in a single transaction that locks all affected rows. Instead: use a soft delete (deleted_at), have a background job process deletes in batches, or use ON DELETE SET NULL for non-critical relationships and clean up orphans later.
Relationships are the backbone of database design. One-to-one is rare. One-to-many is the default — FK always on the "many" side. Many-to-many requires a junction table — treat it as a real entity, not just a connection. LEFT JOIN is your workhorse; INNER JOIN when you only want matches. The N+1 problem is everywhere — spot it by looking for queries inside loops, and fix it with JOINs or batch queries with WHERE IN.
WHERE IN.ON DELETE behavior explicitly.orders.user_id REFERENCES users(id).WHERE IN (two queries: parents + children, joined in code).ON DELETE CASCADE do? Automatically deletes child rows when the parent row is deleted. Dangerous on large tables — can cause long-running transactions.product_name on order_items for historical accuracy.employees.manager_id REFERENCES employees(id). Enables hierarchical data (org charts, comment threads, category trees).ON DELETE SET NULLON DELETE RESTRICT