Constraints
Constraints are the database's immune system — they reject bad data before it ever touches your tables. Learn all six types used in real production systems.
Overview — The 6 Constraint Types
What they are, where they live, what they enforce
At a Glance
All six constraints enforce data integrity at the database level — meaning even if your application has a bug, the database will still reject invalid data.
| Constraint | Enforces | Creates Index? | Nullable? |
|---|---|---|---|
| PRIMARY KEY | Unique + Not Null — exactly one per table | Yes (B-Tree) | Never |
| FOREIGN KEY | Referenced row must exist in parent table | No (add manually) | Yes (NULL = no ref) |
| UNIQUE | No two rows share the same value(s) | Yes (B-Tree) | Yes (NULLs not equal) |
| CHECK | Custom boolean expression must be TRUE | No | Depends on expression |
| NOT NULL | Column must always have a value | No | Never |
| EXCLUSION | No two rows conflict by a custom operator | Yes (GiST/B-Tree) | Yes |
Column-level vs Table-level
Constraints can be defined inline with the column or separately. They behave identically — the difference is syntax and readability.
CREATE TABLE users ( id INT PRIMARY KEY, email TEXT NOT NULL UNIQUE );
CREATE TABLE users ( id INT, email TEXT NOT NULL, CONSTRAINT pk_users PRIMARY KEY(id), CONSTRAINT uq_users_email UNIQUE(email) );
violates constraint "chk_products_price" instead of a cryptic pg error. Convention: pk_table, fk_table_col, uq_table_col, chk_table_col.
PRIMARY KEY
The unique, non-null identifier for every row
What it Does
A primary key is UNIQUE + NOT NULL combined. Every table should have exactly one. PostgreSQL automatically creates a B-Tree index on the primary key column(s).
-- Single-column PK (most common) CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY, email TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT pk_users PRIMARY KEY (id) ); -- Shorthand (same result) CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT NOT NULL );
Composite Primary Key
A PK can span multiple columns. Common in many-to-many join tables — the combination must be unique, not the individual columns.
-- Many-to-many: user_roles join table -- A user can have a role only once CREATE TABLE user_roles ( user_id BIGINT NOT NULL, role_id INTEGER NOT NULL, assigned_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT pk_user_roles PRIMARY KEY (user_id, role_id) ); -- ✅ OK — user 1 has role 2, user 1 has role 3 INSERT INTO user_roles(user_id, role_id) VALUES (1, 2); INSERT INTO user_roles(user_id, role_id) VALUES (1, 3); -- ❌ FAILS — user 1 already has role 2 INSERT INTO user_roles(user_id, role_id) VALUES (1, 2);
IDENTITY vs SERIAL vs UUID as PK
| Method | SQL | Notes |
|---|---|---|
| Best IDENTITY | BIGINT GENERATED ALWAYS AS IDENTITY |
SQL standard, prevents accidental overrides |
| Legacy SERIAL | SERIAL / BIGSERIAL |
Old PostgreSQL pattern; still works, just older style |
| Distributed UUID | UUID DEFAULT gen_random_uuid() |
Use when services generate IDs independently |
Adding / Dropping a Primary Key
-- Add a PK after table creation ALTER TABLE users ADD CONSTRAINT pk_users PRIMARY KEY (id); -- Drop a PK ALTER TABLE users DROP CONSTRAINT pk_users; -- View constraints on a table SELECT conname, contype FROM pg_constraint WHERE conrelid = 'users'::regclass;
FOREIGN KEY
Referential integrity — links between tables
What it Does
A foreign key ensures the value in the child column always exists in the parent table's referenced column. It prevents orphaned rows — records that point to nothing.
CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT NOT NULL ); CREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id BIGINT NOT NULL, total NUMERIC(12,2) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ); -- ✅ OK — user 1 exists INSERT INTO users(email) VALUES ('alice@example.com'); -- id = 1 INSERT INTO orders(user_id, total) VALUES (1, 499.00); -- ❌ FAILS — user 999 does not exist INSERT INTO orders(user_id, total) VALUES (999, 99.00);
Referential Actions — ON DELETE / ON UPDATE
What should happen to child rows when the parent row is deleted or updated? PostgreSQL offers five actions.
| Action | On parent DELETE/UPDATE | Use When |
|---|---|---|
NO ACTION (default) |
Error — reject if child rows exist | Strict data integrity (most cases) |
RESTRICT |
Error — same as NO ACTION but checked immediately | Same as NO ACTION for most use cases |
CASCADE |
Auto-delete/update all child rows | Orders → OrderItems, Conversations → Messages |
SET NULL |
Set FK column to NULL in child rows | Optional relationships (created_by → user deleted) |
SET DEFAULT |
Set FK column to its DEFAULT value | Rare — requires a sensible default |
🎮 Interactive: Choose an ON DELETE action — see what happens
-- CASCADE: delete user → all their orders auto-deleted CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ON UPDATE CASCADE -- SET NULL: delete user → orders kept, user_id set to NULL CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL -- E-commerce: cascade order → order_items CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
DEFERRABLE Constraints
By default FK checks happen immediately. Sometimes you need to insert rows in a batch where the parent doesn't exist yet — use DEFERRABLE to defer the check to commit time.
CONSTRAINT fk_orders_user FOREIGN KEY (user_id) REFERENCES users(id) DEFERRABLE INITIALLY DEFERRED; -- checked at COMMIT, not per-statement -- Can also defer per transaction BEGIN; SET CONSTRAINTS fk_orders_user DEFERRED; INSERT INTO orders(user_id, total) VALUES (100, 50); -- user 100 doesn't exist yet INSERT INTO users(id, email) VALUES (100, 'new@example.com'); COMMIT; -- FK checked here — user 100 now exists ✅
⚠️ Always Index FK Columns
PostgreSQL does NOT auto-create an index on the child FK column. Without an index, deleting a parent row requires a full scan of the child table — catastrophic on large tables.
-- Always add this index for every FK column! CREATE INDEX idx_orders_user_id ON orders(user_id); CREATE INDEX idx_order_items_order ON order_items(order_id); CREATE INDEX idx_order_items_product ON order_items(product_id);
orders.user_id column has no index, PostgreSQL does a full sequential scan of the entire orders table for every delete.
UNIQUE
No duplicate values — single or multi-column
Basic UNIQUE
Ensures no two rows have the same value in the constrained column(s). PostgreSQL auto-creates a B-Tree index for each UNIQUE constraint.
CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT NOT NULL, username TEXT NOT NULL, phone TEXT, -- optional CONSTRAINT uq_users_email UNIQUE (email), CONSTRAINT uq_users_username UNIQUE (username) -- phone is nullable; multiple NULLs allowed (NULLs are never "equal") ); -- ✅ OK INSERT INTO users(email, username) VALUES ('alice@x.com', 'alice'); -- ❌ FAILS: duplicate email INSERT INTO users(email, username) VALUES ('alice@x.com', 'alice2');
phone can be NULL for many users.
Composite UNIQUE
Enforce uniqueness across a combination of columns. Very common in many-to-many tables and natural business rules.
-- A user can only like a post once CREATE TABLE post_likes ( user_id BIGINT NOT NULL, post_id BIGINT NOT NULL, liked_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT pk_post_likes PRIMARY KEY (user_id, post_id), CONSTRAINT fk_likes_user FOREIGN KEY (user_id) REFERENCES users(id), CONSTRAINT fk_likes_post FOREIGN KEY (post_id) REFERENCES posts(id) ); -- In a SaaS: org_id + slug must be unique per organization CREATE TABLE projects ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, org_id BIGINT NOT NULL, slug TEXT NOT NULL, CONSTRAINT uq_projects_org_slug UNIQUE (org_id, slug) );
UNIQUE vs PRIMARY KEY
| Feature | PRIMARY KEY | UNIQUE |
|---|---|---|
| Allows NULL | No | Yes |
| Per table | Only one | Multiple allowed |
| Auto index | Yes | Yes |
| Used as FK target | Yes | Yes |
CHECK
Custom business rules enforced at the database level
Basics
A CHECK constraint runs a boolean expression on every INSERT and UPDATE. If the expression returns FALSE, the operation is rejected. If it returns NULL (e.g. when the value is NULL), the check is passed by default.
CREATE TABLE products ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, price NUMERIC(12,2) NOT NULL, discount NUMERIC(5,2), stock_qty INTEGER NOT NULL, rating SMALLINT, CONSTRAINT chk_products_price CHECK (price > 0), CONSTRAINT chk_products_discount CHECK (discount IS NULL OR (discount >= 0 AND discount <= 100)), CONSTRAINT chk_products_stock CHECK (stock_qty >= 0), CONSTRAINT chk_products_rating CHECK (rating IS NULL OR rating BETWEEN 1 AND 5) ); -- ✅ OK INSERT INTO products(name, price, stock_qty) VALUES ('Phone', 999, 50); -- ❌ FAILS: price must be > 0 INSERT INTO products(name, price, stock_qty) VALUES ('Phone', -10, 50);
Cross-Column CHECK
CHECK expressions can reference multiple columns — perfect for business rules that span fields.
CREATE TABLE promotions ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name TEXT NOT NULL, starts_at TIMESTAMPTZ NOT NULL, ends_at TIMESTAMPTZ NOT NULL, min_order NUMERIC(12,2), max_discount NUMERIC(12,2), -- end must be after start CONSTRAINT chk_promo_dates CHECK (ends_at > starts_at), -- if min_order set, it must be positive CONSTRAINT chk_promo_min_order CHECK (min_order IS NULL OR min_order > 0), -- discount must be less than the minimum order value CONSTRAINT chk_promo_discount_reasonable CHECK (max_discount IS NULL OR min_order IS NULL OR max_discount <= min_order) );
CHECK for ENUM-like Behavior
When you want controlled values but need flexibility to change them without recreating a type — use TEXT + CHECK.
CREATE TABLE orders ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, status TEXT NOT NULL DEFAULT 'pending', CONSTRAINT chk_orders_status CHECK (status IN ( 'pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled' )) ); -- To add a new status: just drop and re-add the constraint (no table rewrite) ALTER TABLE orders DROP CONSTRAINT chk_orders_status; ALTER TABLE orders ADD CONSTRAINT chk_orders_status CHECK (status IN ( 'pending', 'confirmed', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded' -- new! )) NOT VALID; -- NOT VALID: skip checking existing rows (fast on big tables) -- Then validate existing rows separately (no lock) ALTER TABLE orders VALIDATE CONSTRAINT chk_orders_status;
NOT NULL
The most common constraint — every required column needs this
Basics
NOT NULL prevents NULL from being inserted into a column. In practice, every required field should be NOT NULL — the more NOT NULL columns you have, the simpler your application logic is (no null checks everywhere).
CREATE TABLE users ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email TEXT NOT NULL, -- required name TEXT NOT NULL, -- required phone TEXT, -- optional (nullable) avatar_url TEXT, -- optional created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- auto-set ); -- ❌ FAILS: email is NOT NULL INSERT INTO users(email, name) VALUES (NULL, 'Alice');
Adding NOT NULL to an Existing Column
Adding NOT NULL to a column with existing NULLs will fail. You need to fill the NULLs first.
-- Step 1: fill existing NULLs UPDATE users SET name = 'Unknown' WHERE name IS NULL; -- Step 2: add NOT NULL constraint ALTER TABLE users ALTER COLUMN name SET NOT NULL; -- Remove NOT NULL (make nullable again) ALTER TABLE users ALTER COLUMN name DROP NOT NULL;
NOT NULL + DEFAULT: A Common Pattern
Combine NOT NULL with a DEFAULT so columns are never null but still auto-filled.
CREATE TABLE articles ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL DEFAULT '', -- empty string default is_published BOOLEAN NOT NULL DEFAULT FALSE, -- starts unpublished view_count INTEGER NOT NULL DEFAULT 0, -- starts at 0 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );
Exclusion Constraints
No two rows can "conflict" by a custom operator — ranges, rooms, time slots
What Problem Does It Solve?
Imagine a hotel booking system: the same room cannot be booked by two guests during overlapping dates. A UNIQUE constraint won't help here because overlapping ranges can have different values. This is exactly what EXCLUSION constraints solve.
Real-World Example: Room Booking
-- Requires the btree_gist extension CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE room_bookings ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, room_id INTEGER NOT NULL, guest_name TEXT NOT NULL, -- tstzrange = a timestamptz range type period TSTZRANGE NOT NULL, CONSTRAINT no_overlapping_bookings EXCLUDE USING gist ( room_id WITH =, -- same room period WITH && -- overlapping periods ) ); -- ✅ OK: different rooms overlap INSERT INTO room_bookings(room_id, guest_name, period) VALUES (101, 'Alice', '[2025-06-01, 2025-06-05)'::TSTZRANGE), (102, 'Bob', '[2025-06-03, 2025-06-07)'::TSTZRANGE); -- ✅ OK: same room, no overlap (Alice leaves before Carol arrives) INSERT INTO room_bookings(room_id, guest_name, period) VALUES (101, 'Carol', '[2025-06-06, 2025-06-10)'::TSTZRANGE); -- ❌ FAILS: room 101 already booked for those dates INSERT INTO room_bookings(room_id, guest_name, period) VALUES (101, 'Dave', '[2025-06-04, 2025-06-08)'::TSTZRANGE);
More Real-World Use Cases
| System | Exclusion Rule | Expression |
|---|---|---|
| Hotel booking | Same room, overlapping dates | room_id = AND period && |
| Doctor scheduling | Same doctor, overlapping appointment slots | doctor_id = AND slot && |
| Resource allocation | Same server, overlapping maintenance windows | server_id = AND window && |
| Conference rooms | Same room, overlapping meeting times | room_id = AND timeslot && |
Range Types
PostgreSQL has built-in range types that work seamlessly with exclusion constraints.
-- Range type examples SELECT '[2025-01-01, 2025-12-31]'::DATERANGE; -- date range (inclusive) SELECT '[2025-01-01, 2025-12-31)'::DATERANGE; -- [ = inclusive, ) = exclusive SELECT '[1, 100]'::INT4RANGE; -- integer range -- && = overlap operator SELECT '[2025-06-01, 2025-06-10)'::DATERANGE && '[2025-06-05, 2025-06-15)'::DATERANGE; -- → true (overlapping!) SELECT '[2025-06-01, 2025-06-05)'::DATERANGE && '[2025-06-05, 2025-06-10)'::DATERANGE; -- → false (adjacent, not overlapping)
Constraint Violation Simulator
Click to see what error each constraint produces
What Error Would You Get?
Click a scenario — see the PostgreSQL error message:
Complete E-Commerce Table with All Constraints
Here's a real production-grade table using all constraint types together:
CREATE TABLE order_items ( id BIGINT GENERATED ALWAYS AS IDENTITY, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INTEGER NOT NULL, unit_price NUMERIC(12,2) NOT NULL, discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 4.1 Primary Key CONSTRAINT pk_order_items PRIMARY KEY (id), -- 4.2 Foreign Keys CONSTRAINT fk_order_items_order FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE, CONSTRAINT fk_order_items_product FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE RESTRICT, -- 4.3 Unique: same product not duplicated in same order CONSTRAINT uq_order_items_order_product UNIQUE (order_id, product_id), -- 4.4 Check constraints CONSTRAINT chk_order_items_qty CHECK (quantity > 0), CONSTRAINT chk_order_items_price CHECK (unit_price > 0), CONSTRAINT chk_order_items_discount CHECK (discount_pct >= 0 AND discount_pct <= 100) -- 4.5 NOT NULL already inline on each column above ); -- Indexes for FK columns (manual) CREATE INDEX idx_order_items_order ON order_items(order_id); CREATE INDEX idx_order_items_product ON order_items(product_id);
Quick Quiz
Test your constraint knowledge