PRIMARY KEY, FOREIGN KEY, UNIQUE, NOT NULL and CHECK Constraints
corebeginnerPRIMARY KEY uniquely identifies a row and forbids NULL. FOREIGN KEY requires a value to reference an existing row elsewhere. UNIQUE forbids duplicate values (NULLs excepted). NOT NULL forbids a missing value. CHECK enforces an arbitrary boolean condition on a row. Each rejects a bad write outright rather than allowing it.
Think of it as
Every constraint answers the same underlying question — "what makes a row valid?" — for a different aspect of the data. PRIMARY KEY: can this row be uniquely found? FOREIGN KEY: does this reference actually point somewhere real? UNIQUE: does this value collide with another row's? NOT NULL: is this required fact actually present? CHECK: does this row satisfy an arbitrary business rule? None of them are optional documentation — PostgreSQL enforces every one of them on every write, rejecting the write if violated.
What we're doing: Attempt several writes that each violate a different constraint, and observe PostgreSQL reject every one before anything is stored.
- 10
- No customer with id 99 exists — the foreign key constraint refuses to let this order reference a nonexistent customer.
- 13
- order_number is declared NOT NULL — a NULL value here is rejected before it ever reaches storage.
- 16
- CHECK (total >= 0) evaluates to false for -10 — the row is rejected regardless of every other column being valid.
ERROR: insert or update on table "orders" violates foreign key constraint
DETAIL: Key (customer_id)=(99) is not present in table "customers".
ERROR: null value in column "order_number" of relation "orders" violates not-null constraint
ERROR: new row for relation "orders" violates check constraint "orders_total_check"
DETAIL: Failing row contains (3, 1, A1, -10).Why this works: Each constraint is checked independently at write time, and PostgreSQL reports specifically which one failed — the foreign key check happens before the row could ever reference a nonexistent customer, NOT NULL is checked before an incomplete row is stored, and CHECK is evaluated per row regardless of whether every other constraint would have passed. This is the concrete mechanism behind "the database enforces the rules you declare": none of these three bad rows ever make it into the table, so no later query can ever encounter them.
Relying on application code to enforce an invariant that could be a constraint
Wrong
Better
What you see: A negative total appears in the database despite application code that "always validates before inserting" — traced eventually to a second code path (a script, a migration, a different service, a manual psql session) that never went through the original validation.
Why: Application-level validation only runs in the specific code path it was written for — it protects nothing written through a different path, and a growing system inevitably grows more than one path to the database over time. A CHECK constraint is enforced by PostgreSQL itself on every write regardless of which code, script, or tool performed it, which is why critical invariants belong in the schema rather than solely in application logic.
- PRIMARY KEY — unique + not null identifier
- FOREIGN KEY — must reference a real row
- UNIQUE — no duplicate non-NULL values
- NOT NULL — must be present
- CHECK (expr) — arbitrary boolean rule
What each constraint actually rejects
Together
Remember: PRIMARY KEY, FOREIGN KEY, UNIQUE, NOT NULL and CHECK each enforce a different invariant, and all are checked on every write from any code path — application-level validation only protects the one path it was written for.
See also: composite keys and unique constraints · designing for meaningful null

