Keys and relationships

Make the links between tables real, so an orphan row cannot exist.

16of 22
≈ 13Min read
5Parts

The primary key

One column (or a small set of them) that identifies a row and never changes. Not the name — names change and repeat. Not the email — people switch. A number or a UUID that means nothing outside the database is called a surrogate key, and it is the right default precisely because it has no other job to lose.

and a composite key, where the pair is the identity
create table order_items (
  order_id integer not null references orders(id),
  door_id  integer not null references doors(id),
  qty      integer not null,
  primary key (order_id, door_id)      -- one line per door per order
);

The foreign key

Without it, a mistyped id is simply a row that joins to nothing: the order disappears from every report and nobody knows why. With it, the mistake is rejected at the moment it is made, by the last component that can still tell it is a mistake.

a column that must point at a real row
create table runs (
  id     integer primary key,
  van_id integer not null references vans(id),
  day    text    not null
);

insert into runs (van_id, day) values (999, '2025-03-01');
-- FOREIGN KEY constraint failed
SQLite checks them only when asked

Foreign keys are declared in SQLite but not enforced unless the connection runs pragma foreign_keys = on; — per connection, every time. PostgreSQL and modern MySQL enforce them always. Any real SQLite application turns this on at startup.

One to many, and many to many

A junction table is not a workaround; it is the shape of the relationship. It is also the natural home for the facts about the pairing — the quantity, the price agreed on the day, who approved it.

the id lives on the "many" side
-- one customer, many orders: orders.customer_id
-- one order, many lines:     order_items.order_id

-- many doors in many orders: a table in the middle, holding both ids
create table order_items (
  order_id integer not null references orders(id),
  door_id  integer not null references doors(id),
  qty      integer not null,
  primary key (order_id, door_id)
);

What happens to the children

Choose per relationship, by asking what the business means. Deleting an order should take its lines with it — they have no meaning alone. Deleting a customer should not silently delete four years of orders; that is what restrict is protecting you from.

and the same for on update
van_id integer not null references vans(id) on delete cascade,
fitter_id integer references fitters(id) on delete set null
on deleteWhen the parent row goes
no action / restrictthe delete is refused while children exist — the safe default
cascadethe children are deleted too: right for lines of an order, wrong for orders of a customer
set nullthe child keeps existing with no parent — the job whose fitter left
set defaultrarer: point them at a placeholder row

Indexes come along for the ride, except where they do not

A primary key is indexed automatically everywhere. A foreign key column is not — PostgreSQL and SQLite do not index it for you, and MySQL/InnoDB does. That matters twice: joining on it is a table scan, and deleting a parent row has to scan the child table to check for children. Index your foreign keys; the Indexes lesson is next but one.

Practice