- Learn
- PostgreSQL
- PostgreSQL constraints: CHECK, UNIQUE, foreign keys, and more
PostgreSQL constraints: CHECK, UNIQUE, foreign keys, and more
Every Postgres constraint type, the exact error each one raises, and how to add constraints to a live table without a long lock.
Quick answer
Postgres constraints are rules the database enforces on every insert and update. A row that breaks one is rejected, whichever application, script, or person sent it.
PostgreSQL has six constraint types:
| Constraint | What it guarantees |
|---|---|
NOT NULL | The column always has a value |
CHECK | A boolean expression is not false for the row |
UNIQUE | No two rows share the same value (or combination of values) |
PRIMARY KEY | UNIQUE plus NOT NULL, one per table |
FOREIGN KEY | The value exists in another table |
EXCLUDE | No two rows conflict under an operator you choose, such as overlapping ranges |
Every example and error message below comes from PostgreSQL 18.6.
Column constraints vs table constraints
You can write a constraint next to a column or as a separate line in the table definition. CHECK (price > 0) above is a column constraint. CONSTRAINT discount_below_price CHECK (...) is a table constraint, which is required when the rule involves more than one column.
If you do not name a constraint, PostgreSQL generates a name such as products_price_check, customers_email_key, or orders_customer_id_fkey. Named constraints produce clearer errors and are easier to drop later, so name anything you might need to reference in a migration.
NOT NULL
NOT NULL is the constraint you will use most. Make it the default for every column and drop it only when a missing value means something.
Since PostgreSQL 18, NOT NULL constraints are stored in pg_constraint like the others, with generated names such as items_qty_not_null. That means you can name one, and you can add one as NOT VALID (covered below).
CHECK constraints
A CHECK constraint takes any boolean expression over the row's own columns:
The multi-column rule works the same way:
CHECK passes when the expression is NULL
A check fails only when the expression is false. NULL > 0 is null, not false, so a null value passes:
That is why discount_price above can be left empty. If you want the value required and valid, combine NOT NULL with the CHECK.
CHECK cannot look at other rows or tables
Rules that depend on other rows belong in a UNIQUE, FOREIGN KEY, or EXCLUDE constraint, or in a trigger. You can get a CHECK past this error by wrapping the query in a function, but PostgreSQL only evaluates it when the row itself changes, so later changes to the other table go unchecked.
You can try NOT NULL, CHECK, and UNIQUE below. MUG-01 already exists. The sandbox runs SQLite in your browser, so the error wording differs from PostgreSQL, but the rows that get rejected are the same.
UNIQUE constraints
A UNIQUE constraint is backed by a unique B-tree index, which PostgreSQL creates for you. Do not add a second index on the same column; the constraint's index already serves lookups. See the CREATE INDEX guide for when you need more.
For a rule across several columns, list them together. This allows a user in many teams but only once per team:
NULLs are distinct by default
A plain UNIQUE column accepts any number of nulls, because null is not equal to null:
Since PostgreSQL 15 you can change that with NULLS NOT DISTINCT:
A unique constraint is also what INSERT ... ON CONFLICT targets. The ON CONFLICT guide covers upserts in detail.
PRIMARY KEY
A primary key is UNIQUE and NOT NULL together, and a table can have only one. PostgreSQL marks every primary key column NOT NULL, even in a composite key:
Declaring two primary keys fails at CREATE TABLE:
Use bigint GENERATED ALWAYS AS IDENTITY for surrogate keys. The ALWAYS part stops clients from writing their own ids by accident:
The CREATE TABLE guide compares identity columns with serial.
FOREIGN KEY constraints
A foreign key requires the value to exist in the referenced table:
It also protects the other direction. Deleting a customer who still has orders fails:
ON DELETE actions
The default, NO ACTION, rejects the delete as above. The other options change the child rows instead:
Deleting author 1 deleted their post, and the comment on that post stayed with post_id set to null:
CASCADE can remove far more than you expect when chains of tables cascade into each other. Use it for rows that have no meaning without their parent, like order lines, and leave the default in place elsewhere.
Foreign key errors at creation time
The referenced columns must have a primary key or unique constraint:
And the column types must be compatible:
PostgreSQL does not index the referencing column for you. orders.customer_id has no index unless you create one, and without it every delete from customers scans orders to look for references.
EXCLUDE constraints
An exclusion constraint generalises UNIQUE: instead of "no two rows are equal", it says "no two rows match under these operators". The classic use is stopping double bookings:
The first three succeed. Back-to-back slots do not overlap because the ranges are half-open, and room 102 is a different room. The fourth overlaps room 101's first booking:
The btree_gist extension is what lets a GiST index compare plain integers with =. Without it:
WITHOUT OVERLAPS in PostgreSQL 18
PostgreSQL 18 adds a shorter form for the same idea on primary and unique keys:
It is still an exclusion constraint underneath, so it needs btree_gist for the int column too.
Work With Your Databases Like A Pro
Query, explore, and manage your databases with a beautiful desktop app and built-in AI.
Download Now
Adding constraints to an existing table
The ALTER TABLE guide covers the general syntax. For constraints:
Adding a constraint to a table that already holds bad data fails:
NOT VALID and VALIDATE CONSTRAINT
On a large table, a plain ADD CONSTRAINT scans every row while holding an ACCESS EXCLUSIVE lock that blocks reads and writes. Split it in two:
This returns immediately without checking existing rows, even though row 3 has a balance of -5. New writes are checked straight away, including updates to old rows:
\d shows the state:
Fix the old rows, then validate:
Validating while bad rows remain fails with the same "violated by some row" error, and the constraint stays NOT VALID.
The point of the split is the lock. Checking pg_locks inside each transaction on PostgreSQL 18:
| Step | Lock on the table |
|---|---|
ADD CONSTRAINT ... NOT VALID | AccessExclusiveLock, held briefly because there is no scan |
VALIDATE CONSTRAINT | ShareUpdateExclusiveLock, which allows reads and writes during the scan |
Foreign keys work the same way. ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID succeeds even with orphaned rows, and VALIDATE CONSTRAINT reports the first one it finds:
PostgreSQL 18 also accepts NOT VALID on a NOT NULL constraint:
On earlier versions, the workaround is a CHECK (email IS NOT NULL) NOT VALID constraint, validated and then followed by SET NOT NULL, which PostgreSQL 12 and later can complete without a second scan.
Adding UNIQUE without blocking writes
NOT VALID does not apply to unique constraints. Build the index concurrently, then attach it:
Deferrable constraints
By default, UNIQUE, PRIMARY KEY, FOREIGN KEY, and EXCLUDE constraints are checked immediately. That can reject a change that would be valid once complete. Swapping two positions is the standard example:
Because the constraint is DEFERRABLE, a transaction can postpone the check until COMMIT:
If the data is still invalid at commit, the COMMIT fails and the transaction rolls back.
Even a single statement can trip a non-deferrable unique constraint. On a plain UNIQUE (pos) column with rows at positions 1, 2, and 3:
Row 1 moved to position 2 before row 2 had moved out of the way. A deferrable constraint accepts this statement.
SET CONSTRAINTS only works on constraints declared DEFERRABLE:
Circular foreign keys
INITIALLY DEFERRED makes deferral the default, which solves tables that reference each other. A team needs a captain, and a player needs a team:
Both inserts succeed and the commit passes. A team whose captain never appears fails at COMMIT:
NOT NULL and CHECK constraints cannot be deferred. Writing CHECK (n > 0) DEFERRABLE fails with misplaced DEFERRABLE clause.
A deferrable unique constraint, even one that is INITIALLY IMMEDIATE, also cannot be the target of an upsert:
Make a constraint deferrable only when you need it.
Listing a table's constraints
\d table_name in psql shows them grouped by type. For a query you can run from any client:
contype is c for check, f for foreign key, n for not-null, p for primary key, u for unique, and x for exclusion. The n rows appear on PostgreSQL 18 and later.
Common errors
| Error | Cause and fix |
|---|---|
null value in column "x" ... violates not-null constraint | A required column was missing. Supply a value or add a DEFAULT. |
violates check constraint "name" | The row fails the expression. The DETAIL line shows the failing row. |
duplicate key value violates unique constraint | The value exists. Use ON CONFLICT if an upsert is what you meant. |
is not present in table "x" | Foreign key target missing. Insert the parent first, or defer the constraint. |
is still referenced from table "x" | Delete the children first, or set an ON DELETE action. |
there is no unique constraint matching given keys | The referenced column needs a primary key or unique constraint. |
is violated by some row | Existing data breaks the new constraint. Add it NOT VALID, clean up, then validate. |
data type integer has no default operator class for access method "gist" | Run CREATE EXTENSION btree_gist; before the exclusion constraint. |
constraint "x" is not deferrable | Recreate it with DEFERRABLE. |
Quick reference
| Task | Syntax |
|---|---|
| Required column | email text NOT NULL |
| Rule on a value | CHECK (price > 0) |
| Rule across columns | CONSTRAINT n CHECK (discount_price < price) |
| Unique value | email text UNIQUE |
| Unique including nulls | UNIQUE NULLS NOT DISTINCT (PG15+) |
| Composite key | PRIMARY KEY (order_id, line_no) |
| Foreign key | customer_id bigint REFERENCES customers (id) |
| Cascade deletes | REFERENCES posts (id) ON DELETE CASCADE |
| No overlapping ranges | EXCLUDE USING gist (room_id WITH =, during WITH &&) |
| Temporal key | PRIMARY KEY (room_id, valid_during WITHOUT OVERLAPS) (PG18+) |
| Add without a long lock | ADD CONSTRAINT ... NOT VALID; then VALIDATE CONSTRAINT n; |
| Check at commit | DEFERRABLE INITIALLY DEFERRED |