Designing a database
From one big table to related tables: keys, constraints, many-to-many and a loader that is safe to rerun.
Create a free account Try part of it here
Why it matters
Code is easy to change; data structures are not. Once a database holds a year of real data and three programs read it, changing its shape is a migration project. So the design deserves thought up front, and it's a skill interviewers probe directly ("design the tables for..."). The good news is that most of it comes down to one idea, store each fact in exactly one place, and a handful of patterns you'll reuse everywhere.
What you'll be able to do
- Spot the problems a flat, spreadsheet-style table causes: repeats, contradictions and lists in cells
- Split data into tables so each fact is stored once, linked by primary and foreign keys
- Model a many-to-many relationship with a link table, including facts about the link itself
- Enforce the rules in the schema with NOT NULL, UNIQUE, CHECK and foreign keys
- Write a loader that turns a flat export into those tables and is safe to run twice
What it covers
- The trouble with one big table
- One fact in one place
- Rules in the schema
- Loading an export, twice-safe
- Get or create a trip
- Getting the flat view back
- Worked example: when to bend the rules
The trouble with one big table
The trail club tracks trip sign-ups in a spreadsheet, one row per person per trip. It works for a while:
import pandas as pd rows = [ ("ana@ex.org", "Ana Ruiz", "Fern Hollow loop", "2025-06-14", "hiker", "tent, stove"), ("bo@ex.org", "Bo Chen", "Fern Hollow loop", "2025-06-14", "leader", ""), ("Ana@ex.org ", "Ana Ruíz", "Red Mesa climb", "2025-06-21", "hiker", "harness"), ("cy@ex.org", "Cy Okafor", "Red Mesa climb", "2025-06-22", "leader", "harness, rope"), ("bo@ex.org", "Bo Chen", "Red Mesa climb", "2025-06-21", "sweep", ""), ] signups = pd.DataFrame(rows, columns=["email", "name", "trip", "trip_date", "role", "gear"]) print(signups) print(signups.groupby("trip")["trip_date"].unique())
Look at what's already gone wrong in five rows:
- The same fact, stored many times, disagrees. Ana's name is spelled two ways, and her email has a capital and a stray space in one row. The Red Mesa climb has two different dates. Which is right? The table can't say.
- A list is squeezed into one cell. "Who rented a harness?" now means splitting text, and a typo like
harnessilently becomes a different item. - Some facts have nowhere to live. A trip that nobody has signed up for yet can't be recorded at all, and deleting the last sign-up for a trip deletes the trip too.
These are called update, insert and delete anomalies, and they all come from the same cause: one table mixing facts about different things (members, trips, gear) with facts about how they're connected.
Show the answer
The trip's date is stored once per sign-up, so copies can disagree
A fact about the trip is repeated in every sign-up row. Store it once, in a trips table, and there's nothing to disagree with.
One fact in one place
The fix is to give each kind of thing its own table, and connect them with keys:
members: one row per person. Their name lives here, once.trips: one row per trip, with its single date.signups: one row per person per trip. It holds only the two links, amember_idand atrip_id, plus what's true of that particular sign-up: the role. This is a link table (or join table), and it's how a many-to-many relationship works: a member goes on many trips, and a trip has many members.gearandrentals: the same pattern again, replacing the list in a cell.
Each table gets a primary key: a column whose value is unique and never changes, so other tables can point at the row. A plain integer id is the usual choice. A real-world identifier like an email still matters, so it gets a UNIQUE constraint as the logical key: the database refuses a second member with the same email, even though rows are linked by id. A column that points at another table's primary key is a foreign key.
Splitting a table this way, until every fact depends on the key, the whole key and nothing but the key, is called normalization.
Ask of each column: is it a fact about the member, about the trip, or about this person on this trip?
Rules in the schema
A schema can enforce the rules itself, so no program can break them by accident: NOT NULL (a value is required), UNIQUE, CHECK (any condition), and REFERENCES (a foreign key must point at a row that exists). SQLite checks foreign keys only after PRAGMA foreign_keys = ON, so set it on every connection.
import sqlite3 db = sqlite3.connect(":memory:") db.execute("PRAGMA foreign_keys = ON") db.executescript(""" CREATE TABLE members ( id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL ); CREATE TABLE trips ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, day TEXT NOT NULL ); CREATE TABLE signups ( member_id INTEGER NOT NULL REFERENCES members(id), trip_id INTEGER NOT NULL REFERENCES trips(id), role TEXT NOT NULL CHECK (role IN ('hiker', 'leader', 'sweep')), PRIMARY KEY (member_id, trip_id) ); """) db.execute("INSERT INTO members (email, name) VALUES ('ana@ex.org', 'Ana Ruiz')") db.execute("INSERT INTO trips (name, day) VALUES ('Fern Hollow loop', '2025-06-14')") db.execute("INSERT INTO signups VALUES (1, 1, 'hiker')") for bad in [ "INSERT INTO members (email, name) VALUES ('ana@ex.org', 'Someone else')", # email already used "INSERT INTO signups VALUES (1, 1, 'leader')", # same person, same trip "INSERT INTO signups VALUES (1, 99, 'hiker')", # no trip 99 "INSERT INTO signups VALUES (1, 1, 'chef')", # not a role ]: try: db.execute(bad) except sqlite3.IntegrityError as e: print("refused:", e)
The link table's primary key is the pair (member_id, trip_id), a composite key: one sign-up per person per trip. Every refusal above is a bug that would otherwise have reached the data quietly.
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE m (id INTEGER PRIMARY KEY, email TEXT UNIQUE)") db.execute("INSERT INTO m (email) VALUES ('a@x.org')") ok = 0 for e in ["b@x.org", "a@x.org", "A@x.org"]: try: db.execute("INSERT INTO m (email) VALUES (?)", (e,)) ok += 1 except sqlite3.IntegrityError: pass print(ok)
Show the answer
2
b@x.org is new, and a@x.org breaks the UNIQUE rule. But A@x.org gets in: SQLite compares text exactly, capitals included, so to the database it's a different email. That's why the loader cleans the key before inserting.
Loading an export, twice-safe
The club will keep exporting the spreadsheet, so the loader has to turn flat rows into these tables, and running it again on the same file must change nothing. Two habits make that work:
- Clean the logical key first:
email.strip().lower(), so"Ana@ex.org "is the same member. - Get or create: insert a row only if its logical key is new (
INSERT OR IGNORE, which skips a row that would break a UNIQUE rule), then look up its id. Whether the member was new or not, you end with their id.
A loader that gives the same result however many times it runs is idempotent. That's what lets a scheduled job be rerun after a failure without creating duplicates.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE members (id INTEGER PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL); """) def member_id(db, email, name): email = email.strip().lower() db.execute("INSERT OR IGNORE INTO members (email, name) VALUES (?, ?)", (email, name.strip())) return db.execute("SELECT id FROM members WHERE email = ?", (email,)).fetchone()[0] export = [("ana@ex.org", "Ana Ruiz"), ("bo@ex.org", "Bo Chen"), ("Ana@ex.org ", "Ana Ruíz")] for _ in range(2): # load the same export twice print([member_id(db, e, n) for e, n in export]) print(db.execute("SELECT * FROM members").fetchall())
Notice which name won: the first one loaded. Deciding that on purpose (first wins, last wins, or flag the difference for a person) is part of the design, and worth a comment in the code.
Get or create a trip
Write trip_id(db, name, day), the trip version of member_id: trim the name, insert the trip only if no trip has that name, and return its id. Calling it twice with the same name must give the same id.
import sqlite3 db = sqlite3.connect(":memory:") db.execute("CREATE TABLE trips (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, day TEXT NOT NULL)") def trip_id(db, name, day): cur = db.execute("INSERT INTO trips (name, day) VALUES (?, ?)", (name, day)) return cur.lastrowid print(trip_id(db, "Fern Hollow loop", "2025-06-14"))
Getting the flat view back
Normalized tables don't lose anything: a join puts the spreadsheet view back together whenever you want it, now with every repeat guaranteed to agree. Questions about the links become simple, too:
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE members (id INTEGER PRIMARY KEY, email TEXT UNIQUE, name TEXT); CREATE TABLE trips (id INTEGER PRIMARY KEY, name TEXT UNIQUE, day TEXT); CREATE TABLE signups (member_id INTEGER, trip_id INTEGER, role TEXT, PRIMARY KEY (member_id, trip_id)); INSERT INTO members VALUES (1, 'ana@ex.org', 'Ana Ruiz'), (2, 'bo@ex.org', 'Bo Chen'), (3, 'cy@ex.org', 'Cy Okafor'); INSERT INTO trips VALUES (1, 'Fern Hollow loop', '2025-06-14'), (2, 'Red Mesa climb', '2025-06-21'), (3, 'Pine Gap walk', '2025-07-01'); INSERT INTO signups VALUES (1, 1, 'hiker'), (2, 1, 'leader'), (1, 2, 'hiker'), (3, 2, 'leader'), (2, 2, 'sweep'); """) for row in db.execute(""" SELECT t.day, t.name, m.name, s.role FROM signups s JOIN members m ON m.id = s.member_id JOIN trips t ON t.id = s.trip_id ORDER BY t.day, s.role """): print(row) print("on 2+ trips:", db.execute("SELECT m.name FROM members m JOIN signups s ON s.member_id = m.id GROUP BY m.id HAVING COUNT(*) >= 2").fetchall()) print("no leader yet:", db.execute(""" SELECT t.name FROM trips t LEFT JOIN signups s ON s.trip_id = t.id AND s.role = 'leader' WHERE s.trip_id IS NULL""").fetchall())
The last query is the anti-join from the SQL analysis lesson. With the old spreadsheet, "a trip with no leader" for a trip nobody had signed up for couldn't even be asked.
Worked example: when to bend the rules
Normalization is the default, not a law. Here's a deliberate exception: storing an order's price at the time of purchase, even though the product table already has a price.
import sqlite3 db = sqlite3.connect(":memory:") db.executescript(""" CREATE TABLE products (sku TEXT PRIMARY KEY, price REAL NOT NULL); CREATE TABLE order_lines (order_id INTEGER, sku TEXT REFERENCES products(sku), qty INTEGER, unit_price REAL); INSERT INTO products VALUES ('TE-101', 200); INSERT INTO order_lines VALUES (1, 'TE-101', 1, 200); """) db.execute("UPDATE products SET price = 170 WHERE sku = 'TE-101'") # a sale starts db.execute("INSERT INTO order_lines VALUES (2, 'TE-101', 1, 170)") print(db.execute("SELECT order_id, qty * unit_price AS paid FROM order_lines").fetchall()) print(db.execute("SELECT price FROM products").fetchone())
The product's price is today's price; the order line's unit_price is a historical fact, what this customer paid on that day. They look like the same fact but aren't, so storing both is correct, not a repeat. Other common, deliberate exceptions: a reporting table rebuilt every night with everything joined in advance, so dashboards are fast; a cached total that's expensive to compute; and a JSON column for data with no fixed shape, such as a product's spec sheet. Each trades the guarantee of one source of truth for speed or flexibility, so do it on purpose, write down why, and keep one place as the authority.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A member goes on many trips and a trip has many members. How do you store that?