Who this module is for: You completed F-5 and can query data across multiple tables. Now you need to understand how to make the database enforce your rules automatically — so bad data cannot enter even if your application has a bug. Constraints are the database's immune system.
Why Constraints Matter
Imagine a bug in your application that accidentally submits an order with customer_id = 999 — but no customer with ID 999 exists. Without constraints, PostgreSQL happily stores this invalid order. Your data becomes inconsistent: you have orders that reference customers who do not exist.
Or your application has a bug that allows two users to register with the same email address. Now you have duplicate accounts and authentication breaks.
Constraints prevent these problems at the database level — not in your application code, not in your API layer, but at the very last line of defence before data is written to disk.
PRIMARY KEY — Every Row Needs a Unique Identity
A primary key uniquely identifies each row. No two rows can have the same primary key value, and it can never be NULL.
sql
When you insert a row that violates the primary key:
sql
BIGSERIAL vs manual ID: when you use BIGSERIAL, PostgreSQL automatically assigns the next available integer. You never specify id in your INSERT — it is generated for you. This is the correct pattern for most tables.
NOT NULL — Mandatory Fields
NOT NULL prevents a column from ever being left empty.
sql
Rule: if a column must always have a value, add NOT NULL. If the value is sometimes unknown or optional, leave the column nullable. Be intentional — a column without NOT NULL silently accepts missing data.
UNIQUE — No Duplicates Allowed
UNIQUE ensures no two rows have the same value in that column (or combination of columns).
sql
When a unique constraint is violated:
sql
UNIQUE vs PRIMARY KEY:
PRIMARY KEY = unique + not null + the table's main identifier (one per table)
UNIQUE = no duplicates, but NULLs are allowed (and multiple NULLs count as different values in PostgreSQL)
FOREIGN KEY — Referential Integrity
A foreign key ensures that a value in one table references a real row in another table. It is the constraint that makes joins meaningful.
sql
What happens when you delete a referenced row?
By default, trying to delete a customer who has orders fails:
sql
You control this behaviour with ON DELETE:
sql
Which to use:
ON DELETE CASCADE — when child rows have no meaning without the parent (order items without an order)
ON DELETE SET NULL — when the relationship is optional and child rows are still useful alone (posts by a deleted user still exist, author is just unknown)
ON DELETE RESTRICT (PostgreSQL's actual unwritten default is the closely related NO ACTION) — when you want explicit control and prefer to handle deletion in your application
CHECK — Custom Business Rules
CHECK enforces any condition you can express as a boolean SQL expression:
sql
DEFAULT — Automatic Values
DEFAULT specifies the value to use when a column is omitted from an INSERT:
sql
Adding Constraints to Existing Tables
You will often need to add constraints after a table already exists — because you are adding a rule to a live system:
sql
Viewing constraint names
PostgreSQL auto-generates constraint names if you don't provide one. Find them with:
sql
Practical Exercise: A Constrained Blog Schema
Build this schema from scratch, applying appropriate constraints at each step:
sql
Test your constraints:
sql
Summary
Constraint
What it enforces
PRIMARY KEY
Unique + non-null identifier for each row
NOT NULL
Column must always have a value
UNIQUE
No two rows can have the same value (NULLs are distinct)
FOREIGN KEY
Value must reference an existing row in another table
ON DELETE CASCADE
Delete child rows when parent is deleted
ON DELETE SET NULL
Set FK to NULL when parent is deleted
ON DELETE RESTRICT
Block deletion if child rows exist (unwritten default is the closely related NO ACTION)
CHECK
Any boolean expression must be true
DEFAULT
Value to use when column is omitted from INSERT
The philosophy: the database is the last line of defence. Enforce every rule you can at the database level — it is faster, safer, and catches bugs that application-level validation misses.
One gotcha with FOREIGN KEY specifically: PostgreSQL does not automatically create an index on the FK column itself (only on the primary/unique key it points at). If you're deleting or updating rows on the parent table frequently, an unindexed FK column on the child table forces a sequential scan to check for referencing rows — add an explicit index on it if that's your access pattern.
Module F-7 completes Phase 1 — building your first real application schema, connecting from application code, and the basic tools every engineer needs before moving to Phase 2.
Next: F-7 — Your First Real Application Schema →
Knowledge Check
A software team is designing a database schema for a social media platform. They have users and posts tables, where posts.author_id is a foreign key referencing users.id. The product requirement states that when a user account is deleted, their posts should not be automatically removed, but instead, the posts should be explicitly reviewed by a moderator for potential re-assignment to an 'anonymous' user or archival. Which ON DELETE strategy for the posts.author_id foreign key best supports this architectural requirement?
Consider a users table with a PRIMARY KEY on id and a UNIQUE constraint on the email column. During a data migration, an application bug causes several new user records to be inserted with NULL values for their email address, as the email was not yet available. Assuming no NOT NULL constraint is explicitly defined on the email column, what is the expected behavior regarding the UNIQUE constraint on email for these NULL entries in PostgreSQL?
A critical products table in a high-traffic e-commerce system, containing millions of rows, was initially designed without a NOT NULL constraint on its price column. Due to recent data integrity issues, the engineering team decides to add this constraint to ensure all new and existing products have a defined price. The command ALTER TABLE products ALTER COLUMN price SET NOT NULL; is executed during peak hours. What is the most significant production impact or operational consideration during this operation?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- Using BIGSERIAL (auto-incrementing, recommended for most tables)CREATETABLE users ( id BIGSERIAL PRIMARYKEY, email TEXTNOTNULL);-- Using UUID (when you need globally unique IDs)CREATETABLE sessions ( id UUID PRIMARYKEYDEFAULT gen_random_uuid(), user_id BIGINTNOTNULL, created_at TIMESTAMPTZ NOTNULLDEFAULTNOW());-- Composite primary key (rare — when no single column is unique, but a combination is)CREATETABLE order_items ( order_id BIGINTNOTNULL, product_id BIGINTNOTNULL, quantity INTEGERNOTNULL,PRIMARYKEY(order_id, product_id)-- each product appears once per order);
CREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL,-- required price NUMERIC(10,2)NOTNULL,-- required sku TEXT-- optional (can be NULL));-- This fails:INSERTINTO products (name)VALUES('Keyboard');-- ERROR: null value in column "price" of relation "products" violates not-null constraint-- This works:INSERTINTO products (name, price)VALUES('Keyboard',129.99);-- sku is left as NULL — that is fine
-- Single column uniqueCREATETABLE users ( id BIGSERIAL PRIMARYKEY, email TEXTNOTNULLUNIQUE-- no two users can have the same email);-- Unique constraint on multiple columns (combination must be unique)CREATETABLE team_memberships ( user_id BIGINTNOTNULL, team_id BIGINTNOTNULL,UNIQUE(user_id, team_id)-- a user can only be in the same team once);
CREATETABLE customers ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL);CREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTNOTNULLREFERENCES customers(id), total NUMERIC(10,2)NOTNULL);-- This fails because customer_id 999 does not exist:INSERTINTO orders (customer_id, total)VALUES(999,49.99);-- ERROR: insert or update on table "orders" violates foreign key constraint-- DETAIL: Key (customer_id)=(999) is not present in table "customers".
DELETEFROM customers WHERE id =1;-- ERROR: update or delete on table "customers" violates foreign key constraint-- DETAIL: Key (id)=(1) is still referenced from table "orders".
-- Option 1: Cascade — delete the customer's orders tooCREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTREFERENCES customers(id)ONDELETECASCADE, total NUMERIC(10,2)NOTNULL);-- Deleting a customer also deletes all their orders-- Option 2: Set NULL — set customer_id to NULL when customer is deletedCREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTREFERENCES customers(id)ONDELETESETNULL, total NUMERIC(10,2)NOTNULL);-- Order is kept but customer_id becomes NULL (only works if column is nullable)-- Option 3: Restrict — refuse to delete if referenced rows exist. Note: if you-- omit ON DELETE entirely, PostgreSQL's actual default is NO ACTION, not-- RESTRICT — a distinct option that behaves the same for a plain DELETE but-- (unlike RESTRICT) can be deferred to end-of-transaction if the constraint-- is declared DEFERRABLE. Write RESTRICT explicitly if you want that behavior-- guaranteed and non-deferrable.CREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTREFERENCES customers(id)ONDELETERESTRICT, total NUMERIC(10,2)NOTNULL);-- Option 4: Set default valueCREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTDEFAULT0REFERENCES customers(id)ONDELETESETDEFAULT, total NUMERIC(10,2)NOTNULL);
CREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, price NUMERIC(10,2)NOTNULLCHECK(price >=0), discount NUMERIC(5,2)CHECK(discount BETWEEN0AND100),statusTEXTNOTNULLCHECK(statusIN('active','inactive','archived')));-- This fails:INSERTINTO products (name, price,status)VALUES('Keyboard',-10,'active');-- ERROR: new row for relation "products" violates check constraint "products_price_check"-- DETAIL: Failing row contains (1, Keyboard, -10.00, null, active).-- Multi-column check (defined at table level, not column level)CREATETABLE bookings ( id BIGSERIAL PRIMARYKEY, start_date DATENOTNULL, end_date DATENOTNULL,CHECK(end_date > start_date)-- end must be after start);
CREATETABLE posts ( id BIGSERIAL PRIMARYKEY, title TEXTNOTNULL, published BOOLEANNOTNULLDEFAULTfalse, view_count INTEGERNOTNULLDEFAULT0, created_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), updated_at TIMESTAMPTZ NOTNULLDEFAULTNOW());-- All defaults apply:INSERTINTO posts (title)VALUES('My First Post');-- Result:-- id=1, title='My First Post', published=false, view_count=0,-- created_at=NOW(), updated_at=NOW()
-- Add a NOT NULL constraint (requires all existing rows to already be non-null)ALTERTABLE products ALTERCOLUMN price SETNOTNULL;-- Add a UNIQUE constraintALTERTABLE users ADDCONSTRAINT users_email_unique UNIQUE(email);-- Add a FOREIGN KEY constraintALTERTABLE orders
ADDCONSTRAINT orders_customer_fk
FOREIGNKEY(customer_id)REFERENCES customers(id)ONDELETERESTRICT;-- Add a CHECK constraintALTERTABLE products
ADDCONSTRAINT products_price_positive CHECK(price >=0);-- Drop a constraint by nameALTERTABLE products DROPCONSTRAINT products_price_positive;
\d products -- shows constraints in psql-- Or query the catalog:SELECT conname, contype, pg_get_constraintdef(oid)FROM pg_constraint
WHERE conrelid ='products'::regclass;
CREATETABLE authors ( id BIGSERIAL PRIMARYKEY, username TEXTNOTNULLUNIQUE, email TEXTNOTNULLUNIQUE, bio TEXT,-- optional created_at TIMESTAMPTZ NOTNULLDEFAULTNOW());CREATETABLE posts ( id BIGSERIAL PRIMARYKEY, author_id BIGINTNOTNULLREFERENCES authors(id)ONDELETECASCADE, title TEXTNOTNULL, slug TEXTNOTNULLUNIQUE,-- URL-friendly version of title content TEXTNOTNULL,statusTEXTNOTNULLDEFAULT'draft'CHECK(statusIN('draft','published','archived')), published_at TIMESTAMPTZ,-- NULL until published created_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), updated_at TIMESTAMPTZ NOTNULLDEFAULTNOW());CREATETABLE comments ( id BIGSERIAL PRIMARYKEY, post_id BIGINTNOTNULLREFERENCES posts(id)ONDELETECASCADE, author_id BIGINTREFERENCES authors(id)ONDELETESETNULL,-- author_id can be NULL for anonymous comments content TEXTNOTNULLCHECK(LENGTH(content)>=1), created_at TIMESTAMPTZ NOTNULLDEFAULTNOW());CREATETABLE tags ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULLUNIQUE);CREATETABLE post_tags ( post_id BIGINTNOTNULLREFERENCES posts(id)ONDELETECASCADE, tag_id BIGINTNOTNULLREFERENCES tags(id)ONDELETECASCADE,PRIMARYKEY(post_id, tag_id)-- a post can have each tag only once);
-- Insert an authorINSERTINTO authors (username, email)VALUES('alice','alice@example.com');-- Insert a postINSERTINTO posts (author_id, title, slug, content)VALUES(1,'Hello World','hello-world','My first post!');-- Try to insert a post with an invalid status:INSERTINTO posts (author_id, title, slug, content,status)VALUES(1,'Draft Post','draft-post','Content...','pending');-- ERROR: violates check constraint "posts_status_check"-- Try to insert a duplicate slug:INSERTINTO posts (author_id, title, slug, content)VALUES(1,'Another Post','hello-world','Different content');-- ERROR: violates unique constraint "posts_slug_key"-- Delete the author — their post is deleted too (CASCADE)DELETEFROM authors WHERE id =1;SELECT*FROM posts;-- empty — cascaded delete removed it