SQL for Backend Engineers · Schema Design and Constraints

Keys, Constraints and Data Integrity

Identity and UUIDv7 keys, CHECK, UNIQUE (partial and by expression) and foreign keys with ON DELETE, join tables instead of lists, and constraining data that already exists.

  • 12 min read
  • 13 practice questions
  • 3 graded problems
On this page
  1. Description
  2. Keys
  3. NOT NULL, DEFAULT and CHECK
  4. UNIQUE: case, NULLs and conditions
  5. Foreign keys and ON DELETE
  6. Modeling: one fact in one place
  7. Adding rules to tables that hold data
  8. Which constraint enforces which rule?
  9. Where it breaks
  10. Tradeoffs
  11. Check yourself
  12. Key takeaways
  13. Quick revise
  14. Practice

Description

A database's job isn't only to store data - it's to refuse data that's wrong. Picture a SaaS app three years in: two accounts for Ava@Example.com and ava@example.com, revenue counted twice because one customer has two active subscriptions, order lines pointing at orders that no longer exist, a seats value of -3. Every one of those rules was "enforced by the app" - by the signup form, the billing sync, the admin tool. Then someone ran a script, two requests raced, or a new service wrote to the table directly.

Constraints are rules the database itself enforces, whoever is writing. They're simple to declare, and they're the cheapest insurance a backend has. This lesson goes from the basics to the real-world details:

  1. Primary keys - identity columns and UUIDs.
  2. NOT NULL, DEFAULT and CHECK, and the NULL loophole in CHECK.
  3. UNIQUE with case, NULLs and conditions ("one active subscription").
  4. Foreign keys, and what should happen when a parent row is deleted.
  5. Modeling relationships so each fact lives in one place.
  6. Adding rules to tables that already hold data.

The examples start from customers, orders, order_items and articles; every script ran in the sandbox on PostgreSQL 18.

Keys

Every table gets a primary key - unique and never NULL. For a surrogate key, let the database generate it with an identity column, the SQL-standard replacement for serial:

CREATE TABLE teams (
    id   bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL
);
INSERT INTO teams (name) VALUES ('Platform'), ('Payments');
idname
1Platform
2Payments

GENERATED ALWAYS refuses ids from the app - ERROR: cannot insert a non-DEFAULT value into column "id" - unless the insert says OVERRIDING SYSTEM VALUE, which catches code that invents ids. GENERATED BY DEFAULT accepts them.

When ids are created outside one database - by several services, by clients offline, or before a row is saved - use UUIDs. Postgres 18's uuidv7() starts with a millisecond timestamp, so newer keys sort after older ones:

SELECT uuid_extract_version(uuidv7()) AS version,
       uuidv7() < uuidv7()           AS increasing;
versionincreasing
7true

That order matters to the primary-key index. Random version-4 UUIDs (gen_random_uuid()) land all over the B-tree, splitting pages and spreading writes across the whole index; v7 keys append at the end, like an identity. The tradeoff: a v7 id reveals when it was created. Natural keys - an email, a SKU - make poor primary keys because they change; keep them as UNIQUE columns instead.

NOT NULL, DEFAULT and CHECK

NOT NULL is the cheapest constraint and the most often forgotten. DEFAULT fills a column an insert leaves out. CHECK rejects a row when its condition is false - but NULL makes the condition unknown, and unknown passes:

CREATE TABLE coupons (
    code        text PRIMARY KEY,
    percent_off int CHECK (percent_off BETWEEN 1 AND 100)
);
INSERT INTO coupons VALUES ('SPRING', 20), ('MYSTERY', NULL);
codepercent_off
MYSTERYNULL
SPRING20

The coupon with no discount got in. When a value is required, say NOT NULL too. A value out of range is refused: INSERT INTO coupons VALUES ('HUGE', 150) fails with ERROR: new row for relation "coupons" violates check constraint "coupons_percent_off_check".

A CHECK can compare columns (CHECK (ends_at > starts_at)) and state rules such as "a free plan has one seat" (CHECK (plan <> 'free' OR seats = 1)). It can't look at other rows or tables - that's what UNIQUE and foreign keys are for.

UNIQUE: case, NULLs and conditions

UNIQUE is enforced by a unique index. Three details decide whether it enforces what you mean.

Case. UNIQUE (email) treats Ava@Example.com and ava@example.com as different values - both rows go in. Index an expression instead:

CREATE TABLE users (
    id    int PRIMARY KEY,
    email text NOT NULL,
    phone text UNIQUE
);
CREATE UNIQUE INDEX users_email_lower_key ON users (lower(email));
INSERT INTO users VALUES (1, 'Ava@Example.com', NULL), (2, 'liam@example.com', NULL);
INSERT INTO users VALUES (3, 'ava@example.com', '+1 512 555 0100');

ERROR: duplicate key value violates unique constraint "users_email_lower_key"

NULLs. In that script, users 1 and 2 both have a NULL phone, and UNIQUE (phone) let them in: NULL isn't equal to anything, not even NULL. Since Postgres 15, NULLS NOT DISTINCT treats NULLs as equal:

CREATE TABLE devices (
    id     int PRIMARY KEY,
    serial text UNIQUE NULLS NOT DISTINCT
);
INSERT INTO devices VALUES (1, NULL), (2, NULL);

ERROR: duplicate key value violates unique constraint "devices_serial_key"

Conditions. "One active subscription per customer, any number of canceled ones" is a partial unique index - only rows matching its WHERE are in it:

CREATE TABLE subscriptions (id int PRIMARY KEY, customer_id int NOT NULL, status text NOT NULL);
CREATE UNIQUE INDEX one_active_subscription ON subscriptions (customer_id)
    WHERE status = 'active';
INSERT INTO subscriptions VALUES (1, 7, 'canceled'), (2, 7, 'canceled'), (3, 7, 'active');
INSERT INTO subscriptions VALUES (4, 7, 'active');

ERROR: duplicate key value violates unique constraint "one_active_subscription"

Foreign keys and ON DELETE

orders.customer_id REFERENCES customers (id) guarantees every order points at a real customer. What happens when the referenced row is deleted is part of the design:

ON DELETEdeleting a customer who has orders
NO ACTION (the default) or RESTRICTis refused
CASCADEdeletes their orders too
SET NULL or SET DEFAULTkeeps the orders and clears their reference

In the example schema, orders use the default and order items cascade. Deleting customer 1:

DELETE FROM customers WHERE id = 1;

ERROR: update or delete on table "customers" violates foreign key constraint "orders_customer_id_fkey" on table "orders"

Deleting order 10 takes its two items with it:

DELETE FROM orders WHERE id = 10;
SELECT order_id, sku FROM order_items ORDER BY order_id, sku;
order_idsku
11MUG-01

Cascade where the child can't exist without its parent (an order's lines); refuse where a delete would destroy history (a customer's orders). And index the referencing column yourself: Postgres requires the referenced side to be a primary key or unique - so it has an index - but creates none for orders.customer_id. Here orders has only orders_pkey; without an index on customer_id, every customer delete scans all orders to check for references, and so does every "orders for this customer" page.

Modeling: one fact in one place

The example schema as a diagram - each line is a foreign key, read as "one customer has zero or more orders":

places

contains

has

labels

customers

int

id

PK

text

email

UK

orders

int

id

PK

int

customer_id

FK

numeric

total

order_items

int

order_id

FK

text

sku

int

qty

articles

article_tags

int

article_id

PK,FK

int

tag_id

PK,FK

tags

places

contains

has

labels

customers

int

id

PK

text

email

UK

orders

int

id

PK

int

customer_id

FK

numeric

total

order_items

int

order_id

FK

text

sku

int

qty

articles

article_tags

int

article_id

PK,FK

int

tag_id

PK,FK

tags

  • One-to-many: the "many" side holds the foreign key (orders.customer_id).
  • Many-to-many: a join table with a composite primary key - never a comma-separated list. A list can't be joined, indexed or constrained, and searching it goes wrong:
SELECT title FROM articles
WHERE tags LIKE '%sql%'
ORDER BY title;
title
Indexing 101
Moving off MySQL

'%sql%' matches mysql too. With a join table, the pair can't repeat and a link can't point at a missing article or tag:

CREATE TABLE tags (
    id   int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    name text NOT NULL UNIQUE
);
CREATE TABLE article_tags (
    article_id int NOT NULL REFERENCES articles (id) ON DELETE CASCADE,
    tag_id     int NOT NULL REFERENCES tags (id),
    PRIMARY KEY (article_id, tag_id)
);
SELECT conname, contype FROM pg_constraint WHERE conrelid = 'article_tags'::regclass ORDER BY conname;
connamecontype
article_tags_article_id_fkeyf
article_tags_article_id_not_nulln
article_tags_pkeyp
article_tags_tag_id_fkeyf
article_tags_tag_id_not_nulln

Two foreign keys (f) and the composite primary key (p) - and, new in Postgres 18, each NOT NULL is a named constraint (n) too, so it can be added NOT VALID and validated later like the others.

  • Allowed values: a short, fixed list fits CHECK (status IN (...)). A list that changes, or carries data of its own (a plan's price), belongs in a lookup table that a foreign key references - adding a plan is then an INSERT, not a schema change.
  • Derived values: a generated column can't disagree with its inputs. In Postgres 18 it is virtual by default - computed when read, stored nowhere; add STORED to compute it on write instead:
ALTER TABLE order_items ADD COLUMN unit_price numeric(10,2) NOT NULL DEFAULT 10.00;
ALTER TABLE order_items ADD COLUMN line_total numeric(10,2)
    GENERATED ALWAYS AS (qty * unit_price);
SELECT order_id, sku, line_total FROM order_items ORDER BY order_id, sku;
order_idskuline_total
10MUG-0120.00
10TEA-0210.00
11MUG-0110.00

Adding rules to tables that hold data

A constraint added to an existing table is checked against every row, and the whole statement fails if one breaks it. Order 12 has a total of -5.00:

ALTER TABLE orders ADD CONSTRAINT orders_total_check CHECK (total >= 0);

ERROR: check constraint "orders_total_check" of relation "orders" is violated by some row

So a real migration is usually fix the data, then add the rule. On a large table, split the rule in two:

ALTER TABLE orders ADD CONSTRAINT orders_total_check CHECK (total >= 0) NOT VALID;
UPDATE orders SET total = 0 WHERE total < 0;
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_check;
SELECT conname, convalidated FROM pg_constraint WHERE conname = 'orders_total_check';
connameconvalidated
orders_total_checktrue

NOT VALID enforces the rule on new writes at once (an insert with a total of -1.00 fails) without scanning the existing rows, so it holds its lock only briefly; VALIDATE CONSTRAINT scans later under a lock that doesn't block writes. Foreign keys work the same way. The other everyday cases:

  • A column becomes required: backfill it (UPDATE ... WHERE col IS NULL), then SET NOT NULL. ALTER COLUMN ... SET DEFAULT only affects future inserts - it doesn't fill existing NULLs, and SET NOT NULL still fails.
  • A column becomes unique: decide which duplicate wins, remove or retire the others, then create the unique index - on a busy table with CREATE UNIQUE INDEX CONCURRENTLY, which must run on its own, outside a transaction.
  • Scripts that run everywhere: CREATE ... IF NOT EXISTS and DROP ... IF EXISTS let one migration run safely on databases in different states.

Which constraint enforces which rule?

The ruleConstraint
Every row is identifiablePRIMARY KEY (identity or UUIDv7)
A value must be presentNOT NULL
A value must be in range, or columns must agreeCHECK (plus NOT NULL - CHECK passes NULL)
No two rows share a valueUNIQUE, or a unique index on an expression (lower(email))
At most one row per group in some state ("one active subscription")Partial unique index ... WHERE status = 'active'
A reference must point at a real rowFOREIGN KEY with a deliberate ON DELETE
A value must come from a changing listForeign key to a lookup table
No two rows overlap in time ("no double booking")Exclusion constraint (see the deep dive)
A value derived from othersGenerated column

Where it breaks

  • CHECK passes NULL - add NOT NULL when a value is required.
  • UNIQUE is case-sensitive and lets NULLs repeat - index lower(email); use NULLS NOT DISTINCT if one NULL is the limit.
  • A foreign key without an index on the referencing column - slow deletes and slow joins.
  • CASCADE on a relationship that holds history - one delete removes years of orders.
  • A comma-separated list where a join table belongs.
  • Adding a rule before cleaning the data, or expecting a new DEFAULT to fill old NULLs.

Tradeoffs

  • Constraints vs validation in the app: keep both. The app gives friendly messages; the database guarantees the rule for every writer, script and race.
  • Identity vs UUIDv7: identity ids are compact (8 bytes) and fast; UUIDv7 can be generated anywhere and doesn't reveal how many rows you have, at 16 bytes and a visible creation time.
  • CHECK list vs lookup table vs enum type: CHECK for short fixed lists, a lookup table for lists that change or carry data; an ENUM type is compact but you can't remove a value from it.

Going further. The deep dive (next tab) covers the locks each schema change takes and how to change a busy table safely, deferrable constraints, exclusion constraints for overlapping bookings, how random UUIDs bloat an index, tenant-safe foreign keys, soft deletes, and when to break normalization on purpose.

Check yourself

  1. percent_off int CHECK (percent_off BETWEEN 1 AND 100). Can a row have a NULL percent_off?
Show answer

Yes. A CHECK only rejects a row when its condition is false, and NULL BETWEEN 1 AND 100 is unknown. Add NOT NULL if a value is required.

  1. Each customer may have many addresses but at most one default one. Which constraint enforces that?
Show answer

A partial unique index: CREATE UNIQUE INDEX ON addresses (customer_id) WHERE is_default;. Only default addresses are in the index, so only they must be unique per customer.

  1. ALTER TABLE accounts ALTER COLUMN country SET NOT NULL fails in production. What's the safe sequence?
Show answer

Some rows have a NULL country. Backfill them first (UPDATE accounts SET country = ... WHERE country IS NULL), then SET NOT NULL. Setting a DEFAULT doesn't change existing rows.

Key takeaways

  • Every table has a primary key: an identity column, or uuidv7() when ids are made outside the database. Keep natural keys as UNIQUE columns.
  • CHECK rejects only false - pair it with NOT NULL. UNIQUE is case-sensitive and NULL-tolerant; use expression, partial and NULLS NOT DISTINCT indexes to say exactly what's unique.
  • Choose ON DELETE per relationship, and index foreign-key columns yourself.
  • Model many-to-many with a join table; use lookup tables for value lists that change.
  • On live tables: clean the data, then add the rule - with NOT VALID + VALIDATE and CONCURRENTLY when the table is big.

Quick revise

The whole skill on one page, for the night before an interview.

Everything from the lesson and the deep dive on one page. Skim it before practice or an interview, and open the full lesson for anything that doesn't ring a bell.

Which constraint enforces which rule?

The ruleConstraint
Every row is identifiablePRIMARY KEY - an identity column, or uuidv7() when ids are made outside the database
A value must be presentNOT NULL
A value in range, or columns that agreeCHECK (plus NOT NULL - CHECK passes NULL)
No two rows share a valueUNIQUE, or a unique index on an expression: lower(email)
At most one row per group in some statePartial unique index: ... (customer_id) WHERE status = 'active'
One NULL at mostUNIQUE NULLS NOT DISTINCT (PostgreSQL 15+)
A reference points at a real rowFOREIGN KEY with a deliberate ON DELETE
A value from a list that changesForeign key to a lookup table
No two rows overlap in timeExclusion constraint: EXCLUDE USING gist (room WITH =, during WITH &&)
A value derived from othersGenerated column (virtual by default in PostgreSQL 18; STORED to compute on write)

ON DELETE: NO ACTION/RESTRICT refuse, CASCADE deletes children (an order's lines), SET NULL/SET DEFAULT keep children. Refuse where a delete would destroy history.

Snippets worth knowing by heart

id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
CREATE UNIQUE INDEX users_email_lower_key ON users (lower(email));
CREATE UNIQUE INDEX one_active_subscription ON subscriptions (customer_id) WHERE status = 'active';
CHECK (ends_at > starts_at)
CHECK (plan <> 'free' OR seats = 1)

-- many-to-many
CREATE TABLE article_tags (
  article_id int NOT NULL REFERENCES articles (id) ON DELETE CASCADE,
  tag_id     int NOT NULL REFERENCES tags (id),
  PRIMARY KEY (article_id, tag_id));

-- add a rule to a big live table
SET lock_timeout = '2s';
ALTER TABLE orders ADD CONSTRAINT orders_total_check CHECK (total >= 0) NOT VALID;
UPDATE orders SET total = 0 WHERE total < 0;           -- fix the data (in batches)
ALTER TABLE orders VALIDATE CONSTRAINT orders_total_check;

The traps

  • CHECK passes NULL - pair it with NOT NULL.
  • UNIQUE is case-sensitive and lets NULLs repeat.
  • Postgres doesn't index foreign-key columns for you - index orders.customer_id yourself, or deletes and joins scan.
  • CASCADE on history - one delete removes years of orders.
  • A comma-separated list where a join table belongs (LIKE '%sql%' matches mysql).
  • A new DEFAULT doesn't fill old NULLs - backfill before SET NOT NULL.
  • Adding a rule before cleaning the data - the whole statement fails.
  • Random v4 UUIDs as the primary key scatter writes across the index.

Staff-level points, one line each

  • ADD CONSTRAINT ... CHECK takes an AccessExclusiveLock while it scans every row; NOT VALID takes it for an instant; VALIDATE scans under ShareUpdateExclusiveLock, which lets reads and writes through.
  • Even an "instant" ALTER queues behind a long-running query - and every later query queues behind the ALTER. Always SET lock_timeout and retry.
  • Safe forms: CREATE INDEX CONCURRENTLY; UNIQUE USING INDEX on a concurrently built index; FKs NOT VALID + VALIDATE; SET NOT NULL after a validated CHECK (col IS NOT NULL) skips the scan; a type change = new column, batched backfill, switch, drop.
  • PostgreSQL checks a non-deferrable UNIQUE row by row, so swapping two positions in one UPDATE fails. DEFERRABLE INITIALLY DEFERRED checks at commit - but can't back a foreign key or an ON CONFLICT.
  • "Check for overlap, then insert" races; an exclusion constraint makes double-booking impossible. Half-open ranges let back-to-back bookings through. MySQL and SQL Server have no exclusion constraints.
  • 200,000 random v4 UUIDs: ~8,000-8,300 kB of index; time-ordered v7: 6,184 kB. Random keys split pages and dirty random pages on every insert.
  • Multi-tenant: put tenant_id in every key and foreign key (FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)) so cross-tenant references are impossible; add row-level security for reads.
  • Soft deletes break uniqueness - use ... WHERE deleted_at IS NULL partial indexes, or an archive table.
  • Denormalize on purpose: the price at purchase time is a different fact, not a copy; counters and search copies must be rebuildable from the normalized truth.
  • MySQL ignored CHECK before 8.0.16 and auto-indexes FK columns; SQL Server UNIQUE allows one NULL; Oracle treats '' as NULL.

Say it in an interview

  • "Database constraints plus app validation: the app gives friendly errors, the database guarantees the rule for every writer."
  • "A partial unique index gives one active subscription per customer."
  • "On the big table: NOT VALID, fix the data in batches, then VALIDATE - with a lock_timeout."
  • "To prevent double booking I'd use an exclusion constraint on a tstzrange, not check-then-insert."

Practice

This skill comes with 13 multiple-choice questions that tell you which misconception a wrong answer reveals and 3 graded problems. Your answers are graded and your mastery is tracked, so you'll know when the skill holds up. Running code and saving progress need a free account.

Practice Keys, Constraints and Data Integrity free

One Active Subscription per Customer

MediumProSQL problem

A customer may have many subscriptions over time, but **at most one active one** - past_due and canceled subscriptions don't count. A bug in the billing sync broke that rule, and revenue reports now count some customers twice.

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Normalize Comma-Separated Tags

HardProSQL problem

The blog stores each article's tags as one comma-separated string - 'postgres, SQL' - so "articles tagged sql" can't use an index, and a search for sql also finds mysql. Move the tags into proper tables:

The full statement, the examples and the hidden test suite are part of AI Proof Engineers Pro.

Practice Keys, Constraints and Data Integrity free

No card needed. Lessons, practice questions and easy problems are free forever.

Free plan, no card · 7-day Pro trial · Cancel anytime