How it works
The explanation, with examples you can run.
The kinds
CREATE TABLE customers ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, country text NOT NULL); CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, customer_id bigint NOT NULL REFERENCES customers (id), status text NOT NULL CHECK (status IN ('new', 'paid', 'shipped', 'cancelled')), total_cents integer NOT NULL CHECK (total_cents >= 0), created_at timestamptz NOT NULL);Each rule rejects a bad write with an error that names it:
INSERT INTO orders (customer_id, status, total_cents, created_at) VALUES (999999, 'new', 100, now());ERROR: insert or update on table "orders" violates foreign key constraint "orders_customer_id_fkey"DETAIL: Key (customer_id)=(999999) is not present in table "customers". INSERT INTO orders (customer_id, status, total_cents, created_at) VALUES (1, 'refunded', 100, now());ERROR: new row for relation "orders" violates check constraint "orders_status_check"A CHECK on an account balance (balance_cents >= 0) turns an overdraft bug into an error instead of a negative balance. Check constraints only see the row being written; rules that span rows or tables need a unique or foreign key, an exclusion constraint, or a trigger.
Unique constraints and races
The common bug: look for a row, and insert if it is not there. Two requests can both look before either inserts. A unique constraint is backed by a unique index, and the second insert fails with SQLSTATE 23505. Handle that error, or let the database resolve it:
INSERT INTO customers (email, country)VALUES ('user1@example.com', 'GB')ON CONFLICT (email) DO NOTHING;Watch out for NULLs. Under SQL's rules two NULLs are not equal, so UNIQUE (code, user_id) happily stores ('WELCOME', NULL) twice. Since PostgreSQL 15, UNIQUE NULLS NOT DISTINCT (code, user_id) treats them as equal and rejects the second.
Foreign keys
A foreign key says every orders.customer_id must exist in customers.id. You also choose what happens when the parent row is deleted or its key changes: the default NO ACTION (and RESTRICT) raise an error, CASCADE deletes the children too, SET NULL and SET DEFAULT change the reference. Make that choice deliberately; ON DELETE CASCADE on the wrong relationship deletes a lot.
PostgreSQL does not create an index on the referencing column. Without one, every delete of a customer scans orders to find children, and so do joins on that column. Index it unless the table is tiny.
Adding constraints to a live table
Adding a constraint checks every existing row, holding a lock while it does. For large tables, add foreign keys and checks with NOT VALID (new writes are checked at once), then run ALTER TABLE ... VALIDATE CONSTRAINT, which checks old rows under a weaker lock that does not block writes. For a new unique constraint, build the index with CREATE UNIQUE INDEX CONCURRENTLY and then attach it with ADD CONSTRAINT ... UNIQUE USING INDEX.
Check yourself
3 questions. Get them all right to finish this topic.
0 of 3 right so far. Get all three to finish this topic.