iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

Constraints

Constraints push correctness down to the database. NOT NULL, UNIQUE, CHECK, FOREIGN KEY, and exclusion constraints let Postgres reject bad data before it ever lands — far more reliable than enforcing rules only in application code.

NOT NULL, CHECK, FK, exclusion, deferred

EXAMPLE
-- 1) Column constraints — inline
CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       TEXT NOT NULL UNIQUE,
    age         INT CHECK (age >= 0),
    role        TEXT NOT NULL CHECK (role IN ('admin', 'user', 'guest')) DEFAULT 'user',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 2) Table-level constraints — named
CREATE TABLE products (
    id           BIGSERIAL PRIMARY KEY,
    sku          TEXT NOT NULL,
    price_cents  BIGINT NOT NULL,
    discount     NUMERIC(5,2),
    CONSTRAINT products_sku_unique     UNIQUE (sku),
    CONSTRAINT products_price_positive CHECK (price_cents > 0),
    CONSTRAINT products_discount_range CHECK (discount IS NULL OR (discount >= 0 AND discount <= 1))
);

-- Always name your constraints. Default names are auto-generated and ugly.

-- 3) Foreign keys
CREATE TABLE orders (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    product_id  BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    quantity    INT NOT NULL CHECK (quantity > 0)
);

-- Actions:
--   ON DELETE NO ACTION     default; refuses deletion if rows reference (deferrable)
--   ON DELETE RESTRICT      same as NO ACTION but cannot be deferred
--   ON DELETE CASCADE       delete referencing rows
--   ON DELETE SET NULL      null out the FK
--   ON DELETE SET DEFAULT   use the column DEFAULT

-- 4) Composite primary key
CREATE TABLE user_roles (
    user_id  BIGINT REFERENCES users(id) ON DELETE CASCADE,
    role     TEXT NOT NULL,
    PRIMARY KEY (user_id, role)
);

-- 5) UNIQUE constraint vs UNIQUE index
CREATE TABLE invitations (
    id       BIGSERIAL PRIMARY KEY,
    email    TEXT NOT NULL,
    accepted BOOLEAN NOT NULL DEFAULT false
);

-- Conditional uniqueness — only ONE outstanding invite per email
CREATE UNIQUE INDEX invitations_one_open_per_email
    ON invitations (email) WHERE accepted = false;

-- 6) Multi-column CHECK
CREATE TABLE bookings (
    id          BIGSERIAL PRIMARY KEY,
    start_at    TIMESTAMPTZ NOT NULL,
    end_at      TIMESTAMPTZ NOT NULL,
    CHECK (end_at > start_at)
);

-- 7) Exclusion constraints — like UNIQUE but with arbitrary operators
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE bookings2 (
    id           BIGSERIAL PRIMARY KEY,
    room_id      BIGINT NOT NULL,
    period       TSTZRANGE NOT NULL,
    EXCLUDE USING GIST (room_id WITH =, period WITH &&)
    -- no two rows for the SAME room with OVERLAPPING time ranges
);

INSERT INTO bookings2 (room_id, period)
VALUES (1, tstzrange('2025-01-01 09:00+00', '2025-01-01 10:00+00'));
-- Second insert that overlaps fails:
INSERT INTO bookings2 (room_id, period)
VALUES (1, tstzrange('2025-01-01 09:30+00', '2025-01-01 11:00+00'));
-- ERROR: conflicting key value violates exclusion constraint

-- 8) Deferrable constraints — defer to end of transaction
CREATE TABLE accounts (
    id        BIGSERIAL PRIMARY KEY,
    balance   BIGINT NOT NULL,
    CONSTRAINT positive_balance CHECK (balance >= 0) DEFERRABLE INITIALLY DEFERRED
);

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- temporarily negative OK
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;  -- both checks happen here

-- 9) Domains — reusable typed constraints
CREATE DOMAIN email_address AS TEXT
    CHECK (VALUE ~ '^[^@\s]+@[^@\s]+\.[^@\s]+$');

CREATE DOMAIN positive_money AS BIGINT
    CHECK (VALUE > 0);

CREATE TABLE customers (
    id     BIGSERIAL PRIMARY KEY,
    email  email_address NOT NULL,
    spend  positive_money
);

-- 10) Generated columns
CREATE TABLE orders2 (
    id              BIGSERIAL PRIMARY KEY,
    subtotal_cents  BIGINT NOT NULL,
    tax_cents       BIGINT NOT NULL GENERATED ALWAYS AS (subtotal_cents / 10) STORED,
    total_cents     BIGINT NOT NULL GENERATED ALWAYS AS (subtotal_cents + subtotal_cents / 10) STORED
);

-- 11) Adding constraints to existing tables
ALTER TABLE products ADD CONSTRAINT products_sku_format CHECK (sku ~ '^[A-Z0-9-]+$');
ALTER TABLE products ADD CONSTRAINT products_price_positive CHECK (price_cents > 0);
ALTER TABLE products ADD CONSTRAINT fk_supplier FOREIGN KEY (supplier_id) REFERENCES suppliers(id);
ALTER TABLE products DROP CONSTRAINT products_sku_format;

-- For huge tables, validate in two steps:
ALTER TABLE products ADD CONSTRAINT chk_price CHECK (price_cents > 0) NOT VALID;
-- ... let it validate without locking the table
ALTER TABLE products VALIDATE CONSTRAINT chk_price;

-- 12) Constraint introspection
SELECT conname, contype, pg_get_constraintdef(oid)
FROM pg_constraint
WHERE conrelid = 'products'::regclass;

-- contype: p=primary, u=unique, c=check, f=foreign, x=exclusion, t=trigger

-- 13) Common patterns
--   • Soft delete: deleted_at TIMESTAMPTZ; partial unique 'WHERE deleted_at IS NULL'
--   • Status fields: CHECK on enum-like text values
--   • Money: BIGINT cents + CHECK (>= 0)
--   • Optional FK: column NULL allowed, FK still enforced when present
--   • Cascading deletes only where ownership is clear (orders → order_lines: yes; users → orders: think twice)

-- 14) Performance + correctness trade-offs
--   • Foreign keys with indexes on both sides — joins fast; deletes also fast
--   • CHECK constraints are evaluated on every insert/update
--   • Exclusion constraints use GIST indexes — slower than B-tree but correct
--   • Deferred constraints help complex multi-row updates within a transaction
--   • UNIQUE indexes are HOT-pages on inserts — partitioning helps at extreme scale

-- 15) Common bugs
--   • Missing NOT NULL on FK columns → orphan-looking NULLs
--   • CHECK with NULL — three-valued logic; 'x = 1' is NULL when x is NULL (constraint passes!)
--     Use IS NULL / IS NOT NULL explicitly
--   • Forgot ON DELETE CASCADE → deletion blocked or orphans created
--   • CHECK referencing another table → not supported; use a trigger
--   • Renaming a constraint without updating migrations → drift between schema + DBs
--   • Long ALTER TABLE … ADD CONSTRAINT → table lock; use NOT VALID + VALIDATE
--   • Multiple users hitting a unique constraint → handle the unique-violation error gracefully
--   • Domain changes affect every column using it — communicate before altering
--   • Deferrable constraint not actually deferred — SET CONSTRAINTS ALL DEFERRED inside the txn

Why it matters

Push correctness into Postgres with NOT NULL, UNIQUE, CHECK, foreign keys, exclusion constraints, domains, and generated columns. The database refuses bad data even when the app forgets, partial unique indexes express conditional rules, and deferred constraints rescue tricky multi-row updates inside a single transaction.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
CREATE TABLE orders (
    id bigserial PRIMARY KEY,
    user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    total numeric(10,2) CHECK (total >= 0),
    UNIQUE (user_id, id)
);
Try it Yourself »

Discussion

Loading…