Foreign keys and referential integrity

SQL · Practical · 10 min

Without them, orphaned rows creep in and every join quietly loses or invents data.

A foreign key declares that a column's values must exist in another table's key. With

customer_id bigint NOT NULL REFERENCES customers (id)

the database rejects an order for a customer that does not exist, and rejects deleting a customer who still has orders. That is referential integrity, enforced on every write, by every application, forever. Application-level checks cannot match it, because concurrent requests and ad-hoc scripts slip past them.

What happens when the referenced row is deleted or its key changes is configurable with ON DELETE / ON UPDATE:

  • NO ACTION / RESTRICT (the default behaviour): refuse the delete while references exist.
  • CASCADE: delete the referencing rows too. Right for owned children, such as order_items of an order; dangerous for anything with independent value.
  • SET NULL / SET DEFAULT: keep the child row and clear the reference.

The referenced columns must have a primary key or unique constraint. The referencing column, however, is not indexed automatically in PostgreSQL (MySQL's InnoDB does create one). Without an index, deleting a customer forces a scan of orders to check for references, and joins from customers to their orders are slow. Index foreign-key columns as a rule.

Foreign keys are also documentation: anyone reading the schema, and many tools, can see how tables connect. When you load data in bulk with references in a tricky order, PostgreSQL lets a constraint be DEFERRABLE INITIALLY DEFERRED, checked at commit instead of per statement.

Example

CREATE TABLE orders (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    customer_id bigint NOT NULL REFERENCES customers (id),
    status      text   NOT NULL DEFAULT 'pending',
    total       numeric(10,2) NOT NULL,
    ordered_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON orders (customer_id);    -- not created automatically

CREATE TABLE order_items (
    order_id   bigint NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
    product_id bigint NOT NULL REFERENCES products (id),
    quantity   integer NOT NULL,
    unit_price numeric(10,2) NOT NULL,
    PRIMARY KEY (order_id, product_id)
);

INSERT INTO orders (customer_id, total) VALUES (999, 10);
-- ERROR: insert or update on table "orders" violates foreign key
--        constraint "orders_customer_id_fkey"

DELETE FROM orders WHERE id = 104;   -- its order_items go too (CASCADE)
DELETE FROM customers WHERE id = 1;  -- ERROR: still referenced from orders

Remember

  • A foreign key guarantees every reference points at an existing row
  • ON DELETE CASCADE for owned children, RESTRICT/NO ACTION otherwise
  • PostgreSQL does not index the referencing column; add the index yourself

Watch out for

  • Relying on application code instead of foreign keys, then finding orphaned rows months later.
  • Putting ON DELETE CASCADE on references to valuable data, so one delete wipes out history.

Check yourself

  1. order_items.order_id references orders(id) ON DELETE CASCADE. What does deleting order 101 do?
  2. Why should you usually create an index on orders.customer_id in PostgreSQL?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK