Index Ai Academy
Phase 7 · Software engineering · Building software

Designing a database

From one big table to related tables: keys, constraints, many-to-many and a loader that is safe to rerun.

About 40 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. The trouble with one big table
  2. One fact in one place
  3. Rules in the schema
  4. Loading an export, twice-safe
  5. Get or create a trip
  6. Getting the flat view back
  7. 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())
It runs right here in your browser, with nothing to install.

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 harnes silently 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.

Quick check
In the spreadsheet above, Cy's row says the Red Mesa climb is on the 22nd, and the other two say the 21st. What's the root cause?
  1. Cy typed it wrong, and there's nothing a design can do
  2. Dates should be stored as numbers
  3. The trip's date is stored once per sign-up, so copies can disagree
  4. pandas changed the date when it read the rows
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, a member_id and a trip_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.
  • gear and rentals: 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.

Try it now

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.

Quick check
How many of the three inserts in the loop succeed?
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)
  1. 0
  2. 1
  3. 2
  4. 3
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:

  1. Clean the logical key first: email.strip().lower(), so "Ana@ex.org " is the same member.
  2. 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.

Drill

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"))
In the lesson, your code is checked as you write it.

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

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())
In the lesson you can change this and run it.

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

Create a free account

Try a quiz question

A member goes on many trips and a trip has many members. How do you store that?

  1. A link table with a member_id and a trip_id per row, plus facts about each sign-up
  2. A trips column in members holding a comma-separated list
  3. A member_id column in trips
  4. Copy each member's details into the trips table

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson