Foreign Keys and Referential Integrity
Guarantee a child row always points at a real parent.
Foreign keys keep every child pointing at a real parent
- customer_id
- order_id
- customer_id
- total_cents
- ordersn-1customers— each order references one real customer
A foreign key (FK) says: "the value in this column must exist as a key in that table." An
orders.customer_id FK to customers.customer_id makes it impossible to insert an order for a
customer who doesn't exist. That guarantee is referential integrity: the backbone of a
trustworthy schema, and the thing that stops orphan rows from ever forming.
Worked example.
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
email TEXT
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
total_cents INTEGER,
FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
ON DELETE RESTRICT
);
Now INSERT INTO orders (order_id, customer_id, total_cents) VALUES (1, 999, 500); fails if
customer 999 doesn't exist.
ON DELETE policies decide what happens to children when a parent is deleted:
| Policy | Behavior |
|---|---|
RESTRICT (or NO ACTION) | Block the parent delete while children exist. Safest default. |
CASCADE | Delete the children too. Use only when children are meaningless without the parent (e.g. order line items when the order dies). |
SET NULL | Null out the child's FK. Requires the FK column be nullable. |
An ON DELETE policy only fires when you actually remove a parent row, and the statement for that is DELETE FROM <table> WHERE <predicate>: the mirror of the SELECT ... WHERE you already know, except it removes the matching rows instead of returning them. With ON DELETE CASCADE, deleting a parent order also clears its child line items in the same step.
Anatomy:
FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE RESTRICT
│ │ │ │
child column parent table parent key delete policy
In the warehouse this differs, and SQLite has a trap. SQLite does not enforce foreign keys unless you turn them on every connection:
PRAGMA foreign_keys = ON;. Forget it and yourREFERENCESclauses parse fine but enforce nothing. Orphans slip right in. Warehouses are the opposite extreme: Redshift, Snowflake, and BigQuery let you declare FKs but don't enforce them at all (they're informational, used by the planner). So in the real warehouse, referential integrity is enforced by your load logic and DQ tests, not the engine. Here in SQLite you get real enforcement, as long as you flip the pragma.
A second SQLite gotcha:
INSERT OR IGNOREdoes not suppress a foreign-key violation. TheOR IGNOREconflict clause only skipsUNIQUE/NOT NULL/CHECK/PRIMARY KEYconflicts. An FK violation still raisesFOREIGN KEY constraint failedand aborts the statement. So the defensive way to "insert only if the parent exists" is a guarded insert:INSERT INTO orders (...) SELECT ... WHERE EXISTS (SELECT 1 FROM customers WHERE customer_id = ...). The row lands only when the parent is present; otherwise it's a clean no-op instead of an error.
Keep it readable / common pitfall. Two pitfalls dominate. First: forgetting
PRAGMA foreign_keys = ON; (always the first line of an FK script). Second: reaching for CASCADE
by default. Cascading deletes are a foot-gun; a single parent delete can silently wipe thousands of
children. Default to RESTRICT and only cascade where the child genuinely cannot outlive the parent.
Recap: an FK forces every child to point at a real parent; choose ON DELETE deliberately
(default RESTRICT), and in SQLite you must run PRAGMA foreign_keys = ON; or enforcement is off.
Execution mode: you write a multi-statement script. It runs against a fresh in-memory SQLite DB
with FK enforcement available, then hidden assertion queries check your FK graph, its ON DELETE
policies, and that referential integrity actually held.
Apply
Your turn
The task this lesson builds to.
Create customers (PK customer_id, plus email) and orders (order_id PK,
customer_id NOT NULL, total_cents) with an FK orders.customer_id → customers.customer_id using
ON DELETE RESTRICT. Turn FK enforcement on as the first line. Insert one customer and one
valid order for that customer. Then attempt an order for a non-existent customer (customer_id = 999)
with a guarded insert, INSERT ... SELECT ... WHERE EXISTS (the parent), so the orphan cleanly
does not land (a raw INSERT, even INSERT OR IGNORE, would raise an FK error and abort).
4 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Create a three-table order schema (customers, orders, order_items) plus a
products table, with a defensible ON DELETE policy per relationship:
orders.customer_id → customers:ON DELETE RESTRICT(never lose orders because a customer was deleted).order_items.order_id → orders:ON DELETE CASCADE(line items are meaningless without their order).order_items.product_id → products:ON DELETE RESTRICT.
Turn FK enforcement on. Insert one customer, two products, and two orders for that customer, each with
two items referencing real products: order 1 (which you will delete) and order 2 (which must
survive untouched). Then prove three things: (a) an order_items row for a non-existent order
(order_id = 999) does not land. Use a guarded insert (INSERT ... SELECT ... WHERE EXISTS (the order)), since INSERT OR IGNORE will not skip an FK error; (b) DELETE FROM orders WHERE order_id = 1; cascades to remove order 1's two items; and (c) the cascade is surgical: order 2 keeps its
two items, so exactly two order_items rows survive.
4 hints and 8 automated checks are waiting in the workspace.