Primary & Foreign Keys

The last lesson left recipes.user_id as an informal link to users.id — just an INTEGER column, with nothing actually stopping it from pointing at a user that doesn't exist. This lesson makes that relationship real, and covers a SQLite-specific gotcha that trips up nearly everyone the first time they rely on it.

INTEGER PRIMARY KEY Is Special in SQLite

Every SQLite table (unless explicitly created WITHOUT ROWID) has a hidden 64-bit row identifier called rowid. When a table's primary key is declared as exactly INTEGER PRIMARY KEY (a single column, that exact type), SQLite makes that column an alias for the rowid itself, rather than a separate value it has to track:

CREATE TABLE users (
  id       INTEGER PRIMARY KEY,
  username TEXT NOT NULL UNIQUE
);

-- Leave id out entirely and SQLite assigns the next available rowid automatically:
INSERT INTO users (username) VALUES ('chefsonu');
SELECT last_insert_rowid();  -- the new user's id

This is why nearly every table in this track is declared with plain id INTEGER PRIMARY KEY — it gives you free, automatic, unique row numbering without needing anything extra.

AUTOINCREMENT: Real, But Rarely Needed

SQLite has a genuine AUTOINCREMENT keyword (spelled as one word, unlike MySQL's AUTO_INCREMENT), but it does something narrower than its name suggests. Plain INTEGER PRIMARY KEY may reuse the rowid of a previously deleted row; adding AUTOINCREMENT guarantees ids are never reused, at the cost of a little extra bookkeeping overhead:

CREATE TABLE orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  total REAL NOT NULL
);
Note: Most SQLite schemas — including RecipeBox's throughout this track — skip AUTOINCREMENT and use plain INTEGER PRIMARY KEY. Reach for AUTOINCREMENT only when id reuse after a delete would genuinely be a problem, such as ids referenced by an external system that must never be recycled.

Declaring Foreign Keys

Now the real relationship. RecipeBox's reviews table links a user to a recipe with a rating, so it references both users and recipes:

CREATE TABLE recipes (
  id       INTEGER PRIMARY KEY,
  user_id  INTEGER NOT NULL,
  title    TEXT NOT NULL,
  FOREIGN KEY (user_id) REFERENCES users (id)
);

CREATE TABLE reviews (
  id         INTEGER PRIMARY KEY,
  recipe_id  INTEGER NOT NULL,
  user_id    INTEGER NOT NULL,
  rating     INTEGER NOT NULL,
  comment    TEXT,
  FOREIGN KEY (recipe_id) REFERENCES recipes (id),
  FOREIGN KEY (user_id) REFERENCES users (id)
);

Each review now points at exactly one recipe and exactly one user — the same pattern described back in the track's home page: "a review links a user to a recipe with a rating."

The Gotcha: Foreign Keys Are OFF By Default

Here is the single most commonly missed detail in all of SQLite: writing FOREIGN KEY ... REFERENCES in your schema does not, by itself, make SQLite enforce that relationship. For backward-compatibility reasons, foreign key enforcement is disabled by default. Without turning it on, SQLite will silently accept a review pointing at a recipe_id that doesn't exist:

-- Fresh connection, foreign keys NOT yet turned on for it:
INSERT INTO reviews (recipe_id, user_id, rating) VALUES (9999, 1, 5);
-- Succeeds! Even though recipe 9999 doesn't exist. The FOREIGN KEY was declared but never enforced.

The fix is a single pragma, run once per connection, ideally immediately after opening it:

PRAGMA foreign_keys = ON;

-- Now the same insert correctly fails:
INSERT INTO reviews (recipe_id, user_id, rating) VALUES (9999, 1, 5);
-- Error: FOREIGN KEY constraint failed
Note: This pragma is per connection, not a permanent database setting — it has to be run every single time your application (or the CLI) opens a new connection to the database. Most libraries let you set this automatically right after connecting; forgetting to is one of the most common real-world SQLite bugs, silently letting invalid relationships slip into a database whose schema looks perfectly correct.
Next up: Constraints builds on this with NOT NULL, UNIQUE, CHECK, and DEFAULT in full — the other tools SQLite gives you for keeping RecipeBox's data valid.