Topic 08

Foreign Keys: Tables That Point at Each Other

Keys

A screening is "Night Bus, screen 1, Friday at 20:00" — but look back at the screenings table from two pages ago and you won't find the words "Night Bus" anywhere in it. The table stores film_id 3: a pointer to the row in films that already knows everything about Night Bus. That pointing column is a foreign key — a column in one table whose values must exist as primary key values in another.

This is the page where the course's title cashes in. "Relational" has been a promissory word since Chapter 1 — facts point at each other, we said, and the pointing is the information. The foreign key is that pointing, made into a declared, enforced rule. Everything from here to the end of the book stands on it.

Store the Fact Once, Point at It Everywhere

Why not just copy the title into each screening row? It would certainly be easier to read. But Night Bus screens eleven times this month — copying means the title now lives in eleven places, plus the one in films. The day Lora fixes a typo in the title, she must fix it twelve times, and if she misses one, the database now contains two spellings of the same film and no way to tell which is right. One fact stored in many places will eventually disagree with itself.

Pointing inverts the arithmetic. The title lives in exactly one row of films; every screening carries only the small, meaningless film_id that names that row. Fix the typo once, and every screening is instantly "fixed," because none of them ever held the title — they held directions to it. Think of a library catalog card: it cites the book's shelf code rather than re-copying the book, and when the book moves — or gets a corrected cover — every card citing the code is automatically right. Mapped; back to foreign keys.

Declaring the Pointer

Here is screenings again, now with both kinds of key declared — its own primary key, and the foreign key pointing at films:

The screenings table, complete — REFERENCES declares the pointer
CREATE TABLE screenings (
  screening_id  INTEGER PRIMARY KEY,
  film_id       INTEGER REFERENCES films(film_id),
  screen        INTEGER,
  starts_at     TIMESTAMP,
  price         NUMERIC(6,2)
);

The new clause reads as a sentence: film_id REFERENCES films(film_id) — "values in this column must exist in the film_id column of the films table." With that one line, the database takes on a new enforcement duty. A pointer aimed at a row that doesn't exist is called a dangling pointer, and the database now refuses to ever let one into the building:

A screening for a film that doesn't exist — refused
INSERT INTO screenings (screening_id, film_id, screen, starts_at, price)
VALUES (15, 99, 2, '2026-07-18 17:30', 11.50);

The Marquee has three films, with ids 1, 2, and 3. This INSERT points its film_id at 99 — no such film — and the database hands it straight back: the reference cannot be satisfied, so nothing is stored. "The database says no," a third time this chapter, now guarding the connections between tables. A spreadsheet, for comparison, will happily let a bookings row cite a showtime that was deleted last week.

One film, many screenings — every pointer must land on a real row
films ← screenings, connected by film_id
films — the one rowfilm_id 3 · Night Bus · 98 min
screenings — the many pointersFri 20:00 · film_id 3 ✓Sat 17:30 · film_id 3 ✓Sun 20:15 · film_id 3 ✓✗ film_id 99 — refused, no such film
Every film_id in screenings must exist as a primary key in films — that is the whole foreign-key contract.

One-to-Many, and Which Side Points

The picture above has a shape worth naming. One film has many screenings; each screening belongs to exactly one film. That shape is called a one-to-many relationship, and it is the most common arrangement in every database you'll ever open. Notice where the foreign key lives: on the many side, always. Each screening carries one film_id; the film carries nothing about screenings at all — the many rows point at the one, never the reverse.

Practice reading the direction out loud, because it trips everyone at first: "screenings.film_id points at films.film_id" — the column lives in screenings, its values must exist in films. When you meet an unfamiliar schema, finding the foreign keys and saying each one aloud this way is how professionals actually read it: many screenings per film, many bookings per customer, many orders per shopper.

What Deletion Now Means

One consequence arrives with the pointer, and honesty requires naming it now. Suppose Lora tries to delete Night Bus from films while three screenings still point at it. The database objects — deleting the row would turn three valid pointers into dangling ones, the exact thing it promised to prevent. By default, the deletion is simply refused until the screenings are dealt with.

There are options — the schema can be told to forbid such deletions outright, or to cascade them, deleting the pointing rows along with the pointed-at one. Choosing between those is a real design conversation, and it belongs to Chapter 6; this page only flags that the choice exists. For now, the default behavior is the right instinct: connected data doesn't quietly disappear.

Common Confusions
  • "A foreign key is a key that came from another table." Half right, and the half matters: it is this table's own column, whose values must exist as primary key values there. The column lives here; the rule reaches over there.
  • "Why not just copy the title? It's simpler." Simpler until the first correction. One fact stored in twelve places will eventually disagree with itself, and then nobody knows which copy is true. This disease has a proper name — Chapter 6 introduces it as the reason behind normalization.
  • "The arrow points from films to screenings." Other way around. The pointing column lives in screenings and points at films — the many point at the one. The film knows nothing about its screenings; it doesn't need to.
  • "REFERENCES is just documentation." It's enforced, on every write, forever: an INSERT with film_id 99 is refused, and deleting a pointed-at film is objected to. It's a working guard, not a comment.
Why It Matters
  • Every real schema is a web of these pointers. Reading foreign-key direction — many side points at one side — is literally how you read any database you will ever meet at work.
  • "The database refuses a dangling pointer" is data integrity you get for free, on every write, forever. Spreadsheets have nothing remotely like it — this is a large part of what you came to databases for.

Knowledge Check

One film has many screenings. Which table holds the foreign key, and why?

  • films — it lists the ids of all its screenings
  • screenings — each row carries one film_id pointing at its film
  • Both — each table stores a pointer to the other
  • Neither — the database keeps relationships in a separate hidden table

What does the clause film_id INTEGER REFERENCES films(film_id) make impossible?

  • Two screenings pointing at the same film
  • A film that has no screenings at all
  • A screening whose film_id names a row that doesn't exist in films
  • Changing a screening's start time or its price after the row has been created

Why does the screenings table store film_id 3 instead of the title "Night Bus"?

  • Because numbers take up less storage than titles
  • So the title exists in one place — a correction there fixes every screening at once
  • Because SQL doesn't allow text in more than one table
  • To keep the film titles hidden from anyone who is only reading the screenings table

Lora tries to DELETE the Night Bus row from films while three screenings still point at it. What does the database do by default?

  • Deletes the film and leaves the three screenings pointing at nothing
  • Deletes the film and the three screenings along with it, automatically
  • Refuses the deletion while rows still point at the film
  • Replaces the film with a blank placeholder row so the pointers still work

You got correct