Constraints and keys
Primary, unique and foreign keys, NOT NULL and CHECK — what each one refuses, the NULLs each one lets through, and what happens on delete.
After this lesson you can
- Say what a primary key, a unique constraint and a foreign key each guarantee
- Predict which NULLs pass a UNIQUE, a CHECK and a foreign key
- Choose an ON DELETE action for a foreign key
- Predict everything a cascading delete will touch before running it
A constraint is a rule the database enforces on every write, from every client, forever. Application validation is useful for friendly error messages; a constraint is what makes the rule true.
The five you use every day
PRIMARY KEY— identifies a row: unique and not null. One per table; it may span several columns (a composite key).UNIQUE— no two rows share the value. Unlike a primary key, the column may be nullable.NOT NULL— the value must be present.CHECK (condition)— the row is rejected if the condition is false.FOREIGN KEY ... REFERENCES parent (key)— every non-null value must exist in the parent table's key.
What each one refuses — and lets through
The setup below defines a small helper, try_sql, that runs a statement
and records "ok" or the error message instead of stopping. It is written
in PL/pgSQL, PostgreSQL's procedural language, and it is only plumbing,
so you can watch seven attempts in one run.
CREATE TABLE customers ( id int PRIMARY KEY, email text UNIQUE, credit int CHECK (credit >= 0));CREATE TABLE orders ( id int PRIMARY KEY, customer_id int NOT NULL REFERENCES customers (id));INSERT INTO customers VALUES (1, 'nino@example.com', 100); -- Helper: run a statement, record 'ok' or the error, carry on.CREATE TABLE attempts ( n serial PRIMARY KEY, statement text NOT NULL, outcome text NOT NULL);CREATE FUNCTION try_sql(stmt text) RETURNS void LANGUAGE plpgsql AS $$DECLARE result text;BEGIN BEGIN EXECUTE stmt; result := 'ok'; EXCEPTION WHEN others THEN result := 'error: ' || SQLERRM; END; INSERT INTO attempts (statement, outcome) VALUES (stmt, result);END $$;SELECT try_sql('INSERT INTO customers VALUES (1, ''ana@example.com'', 50)');SELECT try_sql('INSERT INTO customers VALUES (2, ''nino@example.com'', 50)');SELECT try_sql('INSERT INTO customers VALUES (3, ''luka@example.com'', -5)');SELECT try_sql('INSERT INTO customers VALUES (4, NULL, NULL)');SELECT try_sql('INSERT INTO customers VALUES (5, NULL, 0)');SELECT try_sql('INSERT INTO orders VALUES (10, 99)');SELECT try_sql('INSERT INTO orders VALUES (11, NULL)'); SELECT statement, outcome FROM attempts ORDER BY n;The duplicate id, the duplicate email, the negative credit, the order for customer 99 and the order with no customer are all refused. Two rows are accepted that surprise people:
- A
CHECKpasses when its condition is unknown.NULL >= 0is unknown, not false, so aNULLcredit gets in. If the value must also be present, addNOT NULL. UNIQUEallows severalNULLs in PostgreSQL and MySQL, because one unknown is not equal to another. SQL Server'sUNIQUEconstraint allows only one; PostgreSQL 15 addedUNIQUE NULLS NOT DISTINCTfor that behaviour.
A foreign key column that is nullable accepts NULL too — "no parent" —
which is why the orders.customer_id above is declared NOT NULL.
When the parent is deleted
A foreign key also decides what happens to child rows when their parent goes:
NO ACTION(the default) orRESTRICT— the delete is refused while children exist.ON DELETE CASCADE— the children are deleted too.ON DELETE SET NULL— the children stay, with the reference cleared.
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL);CREATE TABLE orders ( id int PRIMARY KEY, customer_id int NOT NULL REFERENCES customers (id) ON DELETE CASCADE);CREATE TABLE order_lines ( id int PRIMARY KEY, order_id int NOT NULL REFERENCES orders (id) ON DELETE CASCADE);CREATE TABLE tickets ( id int PRIMARY KEY, customer_id int REFERENCES customers (id) ON DELETE SET NULL);INSERT INTO customers VALUES (1, 'Nino'), (2, 'Ana');INSERT INTO orders VALUES (10, 1), (11, 1), (12, 2);INSERT INTO order_lines VALUES (1000, 10), (1001, 10), (1002, 12);INSERT INTO tickets VALUES (100, 1), (101, 2);DELETE FROM customers WHERE id = 1; SELECT 'order_lines' AS child_table, id, order_id AS parent_id FROM order_linesUNION ALLSELECT 'orders', id, customer_id FROM ordersUNION ALLSELECT 'tickets', id, customer_id FROM ticketsORDER BY child_table, id;Deleting Nino removed his two orders, then — because order_lines also
cascades from orders — the lines of those orders, and kept his support
ticket with an empty customer_id. A cascade carries on down every level
that is declared CASCADE. That makes cascades convenient and dangerous
in equal measure: one DELETE can remove rows in tables the person typing
it never thought about.
Natural or surrogate keys
A natural key is a real-world value (an email, a country code); a
surrogate key is an id with no meaning. Surrogates never change, which
matters because every foreign key copies the key; keep the natural value
as a UNIQUE column so duplicates are still refused.
Common mistakes
- Relying on application checks alone. Two requests can each check
"email not taken" and both insert; only a
UNIQUEconstraint stops the second. - A
CHECKon a nullable column with noNOT NULL. TheNULLslips past, as the rejections example shows. - Foreign keys without an index on the child column. The constraint does not create one in PostgreSQL or SQL Server, and every parent delete must then scan the child table.
- Adding a constraint to a table that already breaks it. The
ALTER TABLEfails; find the offending rows with a query first.
Exercise · Pro
What one delete will take with it
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade