Skip to main content

Foreign Keys and Referential Integrity

Level 3: Level 3: Data Modeling & Schema Designmedium30 minFOREIGN KEYREFERENCESON DELETEPRAGMA foreign_keys

Guarantee a child row always points at a real parent.

Foreign keys keep every child pointing at a real parent

Schema
customers
  • customer_id
  • email
orders
  • order_id
  • customer_id
  • total_cents
  • ordersn-1customerseach order references one real customer
The FK orders.customer_id must point at a real customers row: referential integrity blocks orphan orders.

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:

PolicyBehavior
RESTRICT (or NO ACTION)Block the parent delete while children exist. Safest default.
CASCADEDelete the children too. Use only when children are meaningless without the parent (e.g. order line items when the order dies).
SET NULLNull 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 your REFERENCES clauses 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 IGNORE does not suppress a foreign-key violation. The OR IGNORE conflict clause only skips UNIQUE / NOT NULL / CHECK / PRIMARY KEY conflicts. An FK violation still raises FOREIGN KEY constraint failed and 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.