Skip to main content

Constraints: NOT NULL, UNIQUE, CHECK, FOREIGN KEY, Cascading

What This Concept Is​

A constraint is a rule the database enforces on the data. The database rejects any statement that would violate it. Constraints are the only place "every row obeys this" is guaranteed; everything else is hope.

Catalog of the core constraints:

  • NOT NULL: the column cannot be null. Forces every row to supply a value.
  • UNIQUE: no two rows may have the same value in this column (or tuple of columns). In standard SQL and PostgreSQL, multiple NULLs are allowed; in some dialects they are not.
  • PRIMARY KEY: NOT NULL + UNIQUE + the canonical row identifier. One per table.
  • CHECK(expr): a boolean per-row predicate. Any row making expr false is rejected.
  • FOREIGN KEY (cols) REFERENCES other(cols): values in cols must match a row in other(cols), or be NULL (unless also NOT NULL).
  • ON DELETE / ON UPDATE actions on foreign keys:
    • NO ACTION (default) / RESTRICT: reject changes that would orphan a child.
    • CASCADE: apply the same delete/update to matching child rows.
    • SET NULL: null out the child's FK columns.
    • SET DEFAULT: replace with the column's default.
  • Deferrable constraints (DEFERRABLE INITIALLY DEFERRED): checked at transaction commit instead of per-statement, needed for circular FKs and bulk loads.

Why It Matters Here​

  • every bug that "just one weird row" causes is a constraint you did not declare
  • application-level validation is not enough: other apps, manual fixes, data imports, and future services will all bypass it
  • cascading rules decide what happens when your data model meets a real DELETE in production; getting them wrong deletes either nothing or too much
  • migrations (Concept 11) live or die on whether new constraints apply to existing rows or only new ones

Concrete Example​

CREATE TABLE customer(
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL CHECK (length(name) >= 1)
);

CREATE TABLE orders(
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL
REFERENCES customer(id) ON DELETE RESTRICT ON UPDATE CASCADE,
total_cents INT NOT NULL CHECK (total_cents >= 0),
currency CHAR(3) NOT NULL CHECK (currency IN ('USD','EUR','GBP','JPY')),
placed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Behavior examples:

INSERT INTO customer(email, name) VALUES (NULL, 'Alice');
-- ERROR: null value in column "email" violates not-null constraint

INSERT INTO orders(customer_id, total_cents, currency) VALUES (999, 100, 'USD');
-- ERROR: insert or update on table "orders" violates foreign key constraint ...

INSERT INTO orders(customer_id, total_cents, currency) VALUES (1, -5, 'USD');
-- ERROR: new row for relation "orders" violates check constraint "orders_total_cents_check"

DELETE FROM customer WHERE id = 1;
-- ERROR: update or delete on table "customer" violates foreign key constraint on table "orders"
-- (because orders.customer_id uses ON DELETE RESTRICT)

ON UPDATE CASCADE is the saving grace for renumbering; ON DELETE RESTRICT is the saving grace for data you never wanted silently deleted.

Common Confusion / Misconception​

"ON DELETE CASCADE is a default good choice." It is a default dangerous choice. Cascades delete an unbounded amount of data if the target happens to be a hub (a root customer or tenant row). Default to RESTRICT; choose CASCADE deliberately for child tables that exist only in service of the parent (e.g., order_item under order).

"CHECK constraints can reference other tables." Standard SQL forbids it, and most engines either refuse or silently cache the snapshot. For cross-table invariants, use triggers, foreign keys, or application-level checks.

"Unique constraints forbid NULL." In PostgreSQL and standard SQL, multiple NULL values are allowed in a UNIQUE column (each NULL is "unknown," so they are not considered equal). If you want one-and-only-one semantics, add NOT NULL.

How To Use It​

For every CREATE TABLE:

  1. Start with the primary key. No exceptions.
  2. Mark every column NOT NULL unless you have a written reason that NULL is meaningful ("unknown" vs "absent").
  3. For every business rule that is always true ("price >= 0", "status in {'new','shipped','cancelled'}"), add a CHECK.
  4. For every reference, add a FOREIGN KEY with an explicit ON DELETE and ON UPDATE. Never leave them to default by accident.
  5. For cross-row or cross-table invariants, reach for triggers only after trying generated columns, UNIQUE constraints over expressions (CREATE UNIQUE INDEX ... ON t(expr)), and exclusion constraints.

Check Yourself​

  1. What is the difference between ON DELETE RESTRICT and ON DELETE NO ACTION in PostgreSQL?
  2. Why is a CHECK(status IN ('new','shipped')) often a better pattern than a status table for small fixed enumerations?
  3. When would you declare a constraint DEFERRABLE INITIALLY DEFERRED?

Mini Drill or Application​

Design a subscription schema with:

  • plan(code, name, monthly_cents) - codes are three-letter strings
  • subscription(id, customer_id, plan_code, started_on, ended_on) - at most one active subscription per customer at a time, active = ended_on IS NULL, price > 0, start date before end date

Write CREATE TABLE statements using NOT NULL, UNIQUE, CHECK, FOREIGN KEY, and a partial unique index for the "at most one active" rule. Explain each constraint in one sentence.

Read This Only If Stuck​