PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 5: Tables and constraints

Tables, constraints and safe ALTER TABLE

Level 1: Foundations25 min

Constraints are the database enforcing your business rules for every client, every script and every future developer. PostgreSQL also runs DDL inside transactions, which makes schema changes testable and reversible.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Define primary, unique, foreign-key, CHECK and NOT NULL constraints
  • Choose foreign-key actions such as ON DELETE CASCADE
  • Change a large table safely with NOT VALID and VALIDATE CONSTRAINT

conceptThe concept

A primary key is unique and not null and gets a unique B-tree index. UNIQUE constraints also create an index. Foreign keys require the referenced columns to be unique; they do not create an index on the referencing side, which is a classic performance trap. CHECK constraints evaluate an expression per row, and table-level CHECKs can compare columns.

Foreign keys choose what happens when the parent row is deleted or its key changes: NO ACTION (default, error), RESTRICT, CASCADE, SET NULL or SET DEFAULT. Constraints can be DEFERRABLE INITIALLY DEFERRED to be checked at commit instead of per statement.

DDL is transactional: you can BEGIN, create and alter tables, test, and ROLLBACK. A few commands (like CREATE INDEX CONCURRENTLY and VACUUM) cannot run inside a transaction block.

whyWhy it matters

Application-level validation misses the batch import, the hotfix script and the second service. A CHECK constraint does not. The flip side is that adding a constraint to a big table scans it while holding a lock, so production changes need the NOT VALID pattern.

Use caseReal-world scenario

Adding a "total must be non-negative" rule to a 500 million row orders table: ADD CONSTRAINT ... NOT VALID takes a brief lock and only checks new rows; VALIDATE CONSTRAINT then scans existing rows under a lock that does not block reads or writes.

Try itExamples

Run and edit these in the interactive lesson.

A table with every kind of constraint
BEGIN;
CREATE TABLE coupons (
  code         text PRIMARY KEY,
  percent_off  integer NOT NULL CHECK (percent_off BETWEEN 1 AND 90),
  valid_from   date NOT NULL,
  valid_to     date NOT NULL,
  customer_id  integer REFERENCES customers (id) ON DELETE CASCADE,
  CONSTRAINT coupons_dates_ok CHECK (valid_to > valid_from)
);
INSERT INTO coupons VALUES ('WELCOME10', 10, '2025-01-01', '2025-12-31', NULL);
\d coupons
ROLLBACK;
Constraints reject bad rows
BEGIN;
CREATE TABLE coupons (
  code text PRIMARY KEY,
  percent_off integer NOT NULL CHECK (percent_off BETWEEN 1 AND 90)
);
INSERT INTO coupons VALUES ('TOO-GOOD', 95);
The INSERT fails with a check-constraint violation, and the console rolls the transaction back for you.
Foreign keys protect parents
BEGIN;
DELETE FROM customers WHERE id = 1;
Customer 1 has orders, and orders.customer_id uses the default NO ACTION, so the delete is refused. The DETAIL line names the referencing row.
Safe changes on a big table
BEGIN;
ALTER TABLE orders ADD COLUMN channel text NOT NULL DEFAULT 'web';
ALTER TABLE orders ADD CONSTRAINT orders_total_nonneg CHECK (total >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_nonneg;
SELECT channel, count(*) FROM orders GROUP BY channel;
ROLLBACK;

internalsUnder the hood

Since PostgreSQL 11, ADD COLUMN ... DEFAULT constant is instant: the default is stored in the catalog and returned for old rows without rewriting the table. A volatile default such as clock_timestamp() or a type change still rewrites the whole table under an ACCESS EXCLUSIVE lock.

Avoid / preferDo this, not that

Avoid
ALTER TABLE orders ADD CONSTRAINT total_not_negative CHECK (total >= 0);
Prefer
ALTER TABLE orders ADD CONSTRAINT total_not_negative CHECK (total >= 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT total_not_negative;

Adding a constraint normally scans the whole table under a lock that blocks writes. NOT VALID applies it to new rows immediately; VALIDATE checks old rows later under a weaker lock.

Avoid
ALTER TABLE orders ADD COLUMN region text NOT NULL DEFAULT compute_region();
Prefer
ALTER TABLE orders ADD COLUMN region text;
-- backfill in batches, then
ALTER TABLE orders ALTER COLUMN region SET NOT NULL;

A constant default is instant, but a volatile default forces a full table rewrite under an exclusive lock.

PitfallsCommon mistakes

  • Forgetting to index foreign-key columns. Deleting a parent row then scans the whole child table, and joins from parent to child cannot use an index.
  • Adding a CHECK or FOREIGN KEY constraint to a large table in one step during business hours. Use NOT VALID, then VALIDATE CONSTRAINT.
  • Running DDL without SET lock_timeout. If it queues behind a long query, every later query queues behind it (the locking lesson shows this).

LimitsWhen not to use it

Do not write triggers for rules a constraint can express. Foreign keys, CHECK, UNIQUE and exclusion constraints are declarative, concurrency-safe and visible to the planner; trigger-based checks are easy to get wrong under concurrency.

SecuritySecurity notes

Constraints are the last line of defence against buggy or malicious clients that bypass application validation. Keep the critical rules (ownership, non-negative balances, valid states) in the database.

ChallengeTry it yourself

Add a rule that delivered orders must have a positive total, without a long write-blocking scan, then validate it and confirm it is validated.

Show one solution
One solution
BEGIN;
ALTER TABLE orders ADD CONSTRAINT delivered_has_total
  CHECK (status <> 'delivered' OR total > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT delivered_has_total;
SELECT conname, convalidated FROM pg_constraint WHERE conname = 'delivered_has_total';
ROLLBACK;

InterviewQuestions you should be able to answer

Does PostgreSQL index foreign keys automatically?
No. The referenced (parent) side must have a unique index, but the referencing (child) column gets no index unless you create one. Without it, each DELETE or key UPDATE on the parent scans the child table to check for references, and joins from parent to children cannot use an index lookup. Index FK columns unless the parent is never deleted from and you never join that way.
How do you add a constraint to a very large, busy table?
Add it as NOT VALID (short lock, enforced for new and updated rows only), then run ALTER TABLE ... VALIDATE CONSTRAINT, which scans existing rows with a SHARE UPDATE EXCLUSIVE lock that does not block normal reads and writes. Set lock_timeout on both steps so they give up instead of queueing behind long transactions.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation