PostgreSQL Cheatsheet

Constraints

Use this PostgreSQL reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

The five kinds

Constraints are enforced by the server, so they hold no matter which application, ORM, or psql session writes the row. Application-level validation is a nicety, a constraint is a guarantee.

ConstraintGuaranteesBacked by an index
PRIMARY KEYUnique and not null, one per tableYes, a unique btree
UNIQUENo duplicate non-null valuesYes, a unique btree
FOREIGN KEYThe referenced row existsNo, index the referencing side yourself
CHECKA boolean expression over one rowNo
NOT NULLColumn is never nullNo
EXCLUDENo pair of rows satisfies an operatorYes, gist or btree
CREATE TABLE order_items (
  order_id   bigint NOT NULL,
  sku        text   NOT NULL,
  qty        integer NOT NULL CONSTRAINT qty_positive CHECK (qty > 0),
  unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0),
  PRIMARY KEY (order_id, sku),
  FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE
);

Name your constraints. An auto-generated name like order_items_qty_check is fine until you need to DROP CONSTRAINT it in a migration and the name differs between environments.

Primary keys and unique

A UNIQUE constraint treats NULLs as distinct by default, so a nullable unique column accepts unlimited NULL rows. NULLS NOT DISTINCT (15+) changes that.

ALTER TABLE customers ADD CONSTRAINT customers_pkey PRIMARY KEY (id);
ALTER TABLE customers ADD CONSTRAINT customers_email_key UNIQUE (email);

-- Pre-15 behavior: many NULL slugs allowed
CREATE TABLE pages (id bigint, slug text UNIQUE);

-- 15+: at most one NULL slug
CREATE TABLE pages (id bigint, slug text UNIQUE NULLS NOT DISTINCT);
ALTER TABLE pages ADD CONSTRAINT pages_slug_key
  UNIQUE NULLS NOT DISTINCT (slug);

-- Attach a constraint to an index you already built concurrently
CREATE UNIQUE INDEX CONCURRENTLY pages_slug_idx ON pages (slug);
ALTER TABLE pages ADD CONSTRAINT pages_slug_key UNIQUE USING INDEX pages_slug_idx;

The USING INDEX form is the zero-downtime recipe: build the unique index concurrently, then promote it to a constraint in a fast catalog-only step.

A partial unique index does what a partial unique constraint cannot, because constraints take no WHERE:

-- Only one active subscription per customer
CREATE UNIQUE INDEX subs_one_active
  ON subscriptions (customer_id) WHERE status = 'active';

Foreign keys

ON DELETE and ON UPDATE decide what happens to the child row when the parent changes. The default is NO ACTION.

ActionOn parent delete / update
NO ACTIONError, but checked at end of statement so deferral can save it
RESTRICTError immediately, never deferred
CASCADEDelete or update the child rows too
SET NULLSet the referencing columns to NULL, needs them nullable
SET DEFAULTSet them to their column default, which must itself reference a real row
SET NULL (col)15+, only the listed columns of a composite key
ALTER TABLE order_items
  ADD CONSTRAINT order_items_order_fk
  FOREIGN KEY (order_id) REFERENCES orders (id)
  ON DELETE CASCADE ON UPDATE RESTRICT;

-- Composite, plus a partial column reset (15+)
ALTER TABLE assignments
  ADD FOREIGN KEY (tenant_id, user_id) REFERENCES users (tenant_id, id)
  ON DELETE SET NULL (user_id);

Two things bite here:

- Postgres does not index the referencing column for you. Every parent DELETE scans the child table without one. Add CREATE INDEX ON order_items (order_id). - A foreign key takes a SHARE ROW EXCLUSIVE lock on the referenced table while it is created, and validates every existing row.

CHECK and NOT NULL

A CHECK expression sees one row and must be immutable. It cannot query another table, cannot use a subquery, and cannot call a volatile function like now(). A row is accepted when the expression is true or NULL, so CHECK (qty > 0) does not imply NOT NULL.

ALTER TABLE orders ADD CONSTRAINT total_nonneg CHECK (total >= 0);
ALTER TABLE orders ADD CONSTRAINT status_known
  CHECK (status IN ('pending','paid','shipped','cancelled'));
ALTER TABLE users ADD CONSTRAINT email_shape CHECK (email ~ '^[^@]+@[^@]+\.[^@]+$');
ALTER TABLE ranges ADD CONSTRAINT ends_after_start CHECK (ends_at > starts_at);

ALTER TABLE orders DROP CONSTRAINT total_nonneg;
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;

CHECK (col IS NOT NULL) and a real NOT NULL are not equivalent: only the latter is visible to the planner, and only the latter lets a column join a primary key.

Adding constraints to a live table

The default ADD CONSTRAINT validates every existing row while holding a strong lock. On a large table, add it NOT VALID first, which only enforces it for new and updated rows, then validate separately under a weaker lock.

-- Step 1, instant, blocks nothing meaningfully
ALTER TABLE orders
  ADD CONSTRAINT total_nonneg CHECK (total >= 0) NOT VALID;

-- Step 2, scans the table under SHARE UPDATE EXCLUSIVE, readers and writers continue
ALTER TABLE orders VALIDATE CONSTRAINT total_nonneg;

The same two-step works for foreign keys. It does not work for SET NOT NULL directly, so the pattern there is: add a NOT VALID check, validate it, then SET NOT NULL, which since 12 can use the validated check to skip its own scan.

ALTER TABLE orders ADD CONSTRAINT updated_at_nn
  CHECK (updated_at IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT updated_at_nn;
ALTER TABLE orders ALTER COLUMN updated_at SET NOT NULL;   -- fast on 12+
ALTER TABLE orders DROP CONSTRAINT updated_at_nn;

Find what is still unvalidated:

SELECT conrelid::regclass AS table, conname, contype
FROM pg_constraint WHERE NOT convalidated;

Deferrable and EXCLUDE

A DEFERRABLE INITIALLY DEFERRED constraint is checked at COMMIT instead of per statement, which is how you swap two unique values without a temporary placeholder. Only UNIQUE, PRIMARY KEY, EXCLUDE, and FOREIGN KEY can be deferrable. CHECK and NOT NULL cannot.

ALTER TABLE pages ADD CONSTRAINT pages_pos_key UNIQUE (position)
  DEFERRABLE INITIALLY IMMEDIATE;

BEGIN;
  SET CONSTRAINTS pages_pos_key DEFERRED;
  UPDATE pages SET position = 2 WHERE id = 1;
  UPDATE pages SET position = 1 WHERE id = 2;
COMMIT;   -- checked here, so the transient duplicate is fine

EXCLUDE generalizes UNIQUE to any operator. With && on a range it is the canonical no-double-booking constraint, and it needs btree_gist for the plain equality column.

CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  room_id int NOT NULL,
  during  tstzrange NOT NULL,
  EXCLUDE USING gist (room_id WITH =, during WITH &&)
);

-- Ignore cancelled rows
ALTER TABLE bookings ADD CONSTRAINT no_overlap
  EXCLUDE USING gist (room_id WITH =, during WITH &&)
  WHERE (status <> 'cancelled');

Reading violation errors

Every constraint failure carries a SQLSTATE. Match on the code, never on the English message, and use constraint_name from the error fields to map it to a user-facing message.

CodeNameMeans
23502not_null_violationA NOT NULL column got NULL
23503foreign_key_violationParent row missing, or child rows still reference it
23505unique_violationDuplicate key, the one you catch for upsert races
23514check_violationA CHECK expression was false
23P01exclusion_violationAn EXCLUDE constraint matched, typically overlapping ranges
23001restrict_violationON DELETE RESTRICT blocked it
22P02invalid_text_representationA cast failed, for example bad uuid or enum text
40001serialization_failureSee Transactions and Locking
ERROR:  duplicate key value violates unique constraint "customers_email_key"
DETAIL:  Key (email)=(ada@example.com) already exists.
SQLSTATE: 23505

Inspect what a table actually enforces:

\d+ orders

SELECT conname, contype, convalidated, condeferrable,
       pg_get_constraintdef(oid) AS definition
FROM pg_constraint
WHERE conrelid = 'orders'::regclass
ORDER BY contype, conname;

contype codes: p primary key, u unique, f foreign key, c check, x exclusion, n not null (18+, earlier releases record NOT NULL in pg_attribute.attnotnull).