Who this module is for: You can write SQL and use constraints. Now you need to design schemas that hold up under real conditions: changing requirements, growing data, and teams who make mistakes. This module covers the principles and decisions that distinguish schemas built to last from schemas that cause pain six months into production.
Normalisation in Plain English
Normalisation is the process of organising tables to reduce data redundancy and improve integrity. Academics describe it in terms of normal forms (1NF, 2NF, 3NF). In practice, you need one intuitive rule:
Each fact should be stored in exactly one place.
If the same piece of information is stored in multiple rows or multiple tables, changing it requires updating multiple places — and when someone forgets one, your data becomes inconsistent.
The redundancy problem
sql
sql
When to intentionally denormalise
Normalisation is the default. Denormalisation is a deliberate trade-off:
High read volume, low update frequency: a reporting table that aggregates data from many tables — it is faster to pre-compute and store than to join 8 tables on every read
Historical snapshots: an orders table should store the price at the time of purchase, not reference the current products.price — prices change, but the order price should not
Audit logs: copy the full state of a row when it changes — redundancy is intentional
sql
Choosing the Right Data Types
Money: NUMERIC, Not FLOAT
sql
Timestamps: TIMESTAMPTZ, Always
sql
Enumerations: TEXT with CHECK vs ENUM type
sql
Recommendation: use TEXT NOT NULL CHECK (... IN ...) for flexibility. Use ENUM only when you need the type enforced at the type system level or need ordering (e.g., priority levels).
Boolean Columns
sql
UUID vs BIGSERIAL
sql
Rule of thumb: use BIGSERIAL for internal tables where sequential IDs are fine. Use UUID (v7 preferred) for public-facing IDs or distributed systems.
Standard Schema Patterns
Audit Fields — On Every Table
Every table in a production system should have these columns:
sql
updated_at does not update automatically — you must update it in your queries or use a trigger:
sql
Soft Deletes — The deleted_at Pattern
Hard deletion (DELETE) permanently removes data and breaks audit trails. Soft deletion marks rows as deleted without removing them.
sql
The soft delete trade-off:
Benefit: data is preserved for audit, recovery, and analytics
Cost: every query must include WHERE deleted_at IS NULL — forgetting it returns deleted records
Cost: unique constraints become complicated (two users with same email — one deleted, one active)
sql
Multi-Tenancy — Isolating Customer Data
For SaaS applications serving multiple organisations:
sql
Row-Level Security (covered in P-6) can enforce this automatically at the database level.
Schema Migration Tools
In Phase 1, we touched on migrations conceptually. Here is the practical toolkit for mid-level engineers.
What a migration file looks like
sql
node-pg-migrate (Node.js)
javascript
bash
Prisma Migrate (Node.js / TypeScript)
prisma
bash
Safe schema changes in production
Not all ALTER TABLE operations are instant. Know which are dangerous:
sql
Practical Exercise: Redesign a Real Schema
Start with a poorly designed schema and improve it:
sql
Redesign it:
sql
Summary
Decision
Recommended Choice
Why
Money
NUMERIC(15,2) or BIGINT (cents)
Exact arithmetic; FLOAT loses precision
Timestamps
TIMESTAMPTZ
Stores UTC; survives timezone changes
String enums
TEXT NOT NULL CHECK (... IN ...)
Easy to add values; no type migration needed
IDs
BIGSERIAL or UUID v7
BIGSERIAL for internal; UUID v7 for public-facing
Audit fields
created_at, updated_at on every table
Essential for debugging and data quality
Soft delete
deleted_at TIMESTAMPTZ
Preserves history; use partial unique indexes
NOT NULL new columns
Add nullable → backfill → add NOT NULL
Safe on live tables
updated_at auto-update
Trigger calling SET NEW.updated_at = NOW()
Consistent across all update paths
Normalisation
Default to normalised; denormalise only with data
Prevents inconsistent data
Module P-5 covers JSON and JSONB — working with semi-structured data in PostgreSQL, when it is the right tool, and when it is a schema design shortcut you will regret.
Next: P-5 — JSON and JSONB — Working With Semi-Structured Data →
Knowledge Check
When designing a database schema that handles financial transactions, a Junior Developer suggests using the FLOAT data type for the price column because it can handle both large numbers and decimals efficiently. As a Senior Engineer reviewing their pull request, what is the most critical architectural reason for rejecting this suggestion?
A team is implementing a "soft delete" feature on their users table by adding a deleted_at TIMESTAMPTZ column. They also need to enforce that email addresses are unique across all active users. A developer adds the standard constraint: ALTER TABLE users ADD CONSTRAINT users_email_key UNIQUE (email);. Why is this implementation flawed in the context of soft deletes, and what is the correct PostgreSQL pattern to solve it?
You need to safely add a region TEXT NOT NULL column to a massive, highly active production table containing millions of orders. Which sequence of operations is the safest pattern to accomplish this without causing extended downtime or table locks?
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.
-- ❌ DENORMALISED: customer city repeated in every orderCREATETABLE orders_bad ( id BIGSERIAL PRIMARYKEY, customer_name TEXTNOTNULL, customer_city TEXTNOTNULL,-- repeated for every order product TEXTNOTNULL, amount NUMERICNOTNULL);-- Alice changes cities — you must update EVERY row with her name-- Miss one row and Alice now lives in two cities simultaneously
-- ✅ NORMALISED: city stored once in the customer recordCREATETABLE customers ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, city TEXTNOTNULL);CREATETABLE orders ( id BIGSERIAL PRIMARYKEY, customer_id BIGINTNOTNULLREFERENCES customers(id), product TEXTNOTNULL, amount NUMERICNOTNULL);-- Alice's city is updated in one place — every order reflects it automatically
-- CORRECT: storing price at order time, not referencing current priceCREATETABLE order_items ( id BIGSERIAL PRIMARYKEY, order_id BIGINTNOTNULLREFERENCES orders(id), product_id BIGINTNOTNULLREFERENCES products(id), quantity INTEGERNOTNULL, unit_price NUMERIC(10,2)NOTNULL-- snapshot of price when ordered-- NOT a foreign key to products.price — that would reflect future prices);
-- ❌ WRONG: floating-point arithmetic loses precisionCREATETABLE invoices_bad (total FLOAT);INSERTINTO invoices_bad VALUES(0.1+0.2);SELECT total FROM invoices_bad;-- 0.30000000000000004 ← wrong! will cause rounding errors in financial totals-- ✅ CORRECT: NUMERIC is exactCREATETABLE invoices (total NUMERIC(15,2));INSERTINTO invoices VALUES(0.1+0.2);SELECT total FROM invoices;-- 0.30 ← exact-- Alternative: store amounts in the smallest unit as BIGINT-- 100 = $1.00, 4999 = $49.99 — eliminates decimal entirelyCREATETABLE orders (total_cents BIGINTNOTNULL);
-- ❌ WRONG: no timezone contextcreated_at TIMESTAMP-- is this UTC? server local time? user local time?-- ✅ CORRECT: timezone-awarecreated_at TIMESTAMPTZ -- stored as UTC, displayed in session timezone-- What goes wrong with bare TIMESTAMP:-- Your server is in UTC. Your user is in Tokyo.-- You store NOW() = '2026-05-17 10:00:00'-- Your server moves to AWS us-east-1 (UTC-4 in winter)-- The same value now reads as '2026-05-17 06:00:00' — 4 hours earlier-- With TIMESTAMPTZ, the offset is stored — this never happens
-- Option 1: TEXT with CHECK constraint (recommended for most cases)statusTEXTNOTNULLCHECK(statusIN('pending','active','suspended','deleted'))-- Adding new values: ALTER TABLE users DROP CONSTRAINT users_status_check;-- ALTER TABLE users ADD CONSTRAINT users_status_check-- CHECK (status IN ('pending', 'active', 'suspended', 'deleted', 'archived'));-- Option 2: PostgreSQL ENUM typeCREATETYPE user_status ASENUM('pending','active','suspended','deleted');CREATETABLE users (status user_status NOTNULL);-- Adding new values: ALTER TYPE user_status ADD VALUE 'archived';-- ⚠️ Cannot remove values from an ENUM without recreating it-- ⚠️ ENUM type comparisons are case-sensitive and order-dependent
-- ✅ CORRECT: explicit NOT NULL with a sensible defaultis_published BOOLEANNOTNULLDEFAULTfalseis_verified BOOLEANNOTNULLDEFAULTfalse-- ❌ PROBLEMATIC: nullable boolean has three states (true/false/unknown)is_admin BOOLEAN-- can be NULL, which might mean "we don't know"-- Use NULL-able boolean only when "unknown" is genuinely meaningful
-- BIGSERIAL: auto-incrementing, sequential, fast for indexesid BIGSERIAL PRIMARYKEY-- ✅ Sequential inserts are fast (B-tree inserts at the end)-- ✅ Human-readable IDs in URLs and logs-- ❌ IDs are guessable (security concern for some APIs)-- ❌ Cannot generate IDs client-side-- UUID v4: random, globally uniqueid UUID PRIMARYKEYDEFAULT gen_random_uuid()-- ✅ Globally unique — safe across distributed systems-- ✅ Not guessable — safe for public-facing IDs-- ❌ Random UUIDs cause index fragmentation (inserts scatter across B-tree)-- ❌ Larger size (16 bytes vs 8 bytes)-- UUID v7: time-ordered, globally unique (best of both)-- PostgreSQL 18+ has this natively; on earlier versions use the 'pg_uuidv7' extensionid UUID PRIMARYKEYDEFAULT uuidv7()-- PG18+; gen_random_uuid() above is v4, NOT v7 —-- swapping in v7 for the index-fragmentation-- fix means changing the DEFAULT itself, not-- just generating v7 client-side against the v4 default-- ✅ Sequential like BIGSERIAL — no index fragmentation-- ✅ Globally unique and unguessable-- ✅ Encodes creation time
CREATETABLE products ( id BIGSERIAL PRIMARYKEY,-- ... your columns ... created_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), updated_at TIMESTAMPTZ NOTNULLDEFAULTNOW());
-- Trigger to auto-update updated_atCREATEORREPLACEFUNCTION set_updated_at()RETURNSTRIGGERAS $$
BEGIN NEW.updated_at =NOW();RETURN NEW;END;$$ LANGUAGE plpgsql;CREATETRIGGER products_set_updated_at
BEFORE UPDATEON products
FOR EACH ROWEXECUTEFUNCTION set_updated_at();-- Now every UPDATE automatically sets updated_at = NOW()UPDATE products SET price =119.99WHERE id =1;-- updated_at is automatically set
CREATETABLE users ( id BIGSERIAL PRIMARYKEY, email TEXTNOTNULLUNIQUE,-- ... other columns ... deleted_at TIMESTAMPTZ -- NULL = active, non-NULL = soft-deleted);-- "Delete" a user (mark as deleted, not actually removed)UPDATE users SET deleted_at =NOW()WHERE id = $user_id;-- Query only active users (common — put this in all relevant queries)SELECT*FROM users WHERE deleted_at ISNULL;-- Or create a VIEW for convenienceCREATEVIEW active_users ASSELECT*FROM users WHERE deleted_at ISNULL;
-- Handling UNIQUE constraint with soft deletes:-- A simple UNIQUE on email blocks creating a new account for a deleted email-- Solution 1: partial unique index (only enforce uniqueness for active users)CREATEUNIQUEINDEX users_email_active_unique
ON users (email)WHERE deleted_at ISNULL;-- Two rows with same email allowed if at least one has deleted_at set-- Solution 2: add a is_deleted boolean (less flexible)-- Solution 3: move deleted records to a separate archive table
-- Every table has a tenant/organisation referenceCREATETABLE organisations ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL);CREATETABLE projects ( id BIGSERIAL PRIMARYKEY, org_id BIGINTNOTNULLREFERENCES organisations(id)ONDELETECASCADE, name TEXTNOTNULL);CREATETABLE tasks ( id BIGSERIAL PRIMARYKEY, org_id BIGINTNOTNULLREFERENCES organisations(id)ONDELETECASCADE, project_id BIGINTNOTNULLREFERENCES projects(id)ONDELETECASCADE, title TEXTNOTNULL);-- Every query filters by org_id — never show one org's data to anotherSELECT*FROM tasks WHERE org_id = $current_org_id AND project_id = $project_id;-- Index for multi-tenant performanceCREATEINDEX idx_tasks_org_project ON tasks (org_id, project_id);
-- migrations/20260517_001_add_task_labels.sql-- Up migration (apply change)CREATETABLE labels ( id BIGSERIAL PRIMARYKEY, org_id BIGINTNOTNULLREFERENCES organisations(id), name TEXTNOTNULL, color TEXTNOTNULLDEFAULT'#3B82F6',UNIQUE(org_id, name));CREATETABLE task_labels ( task_id BIGINTNOTNULLREFERENCES tasks(id)ONDELETECASCADE, label_id BIGINTNOTNULLREFERENCES labels(id)ONDELETECASCADE,PRIMARYKEY(task_id, label_id));CREATEINDEX idx_task_labels_task ON task_labels (task_id);CREATEINDEX idx_task_labels_label ON task_labels (label_id);
# Run pending migrationsDATABASE_URL=postgresql://... node-pg-migrate up
# Roll back last migrationDATABASE_URL=postgresql://... node-pg-migrate down
// schema.prisma
model Task {
id BigInt @id @default(autoincrement())
orgId BigInt
projectId BigInt
title String
status String @default("todo")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
org Organisation @relation(fields: [orgId], references: [id])
project Project @relation(fields: [projectId], references: [id])
labels TaskLabel[]
}
# Generate and apply a migration from schema changesnpx prisma migrate dev --name add-task-labels
# Apply migrations in productionnpx prisma migrate deploy
-- ✅ Instant (no table rewrite) — PG11+, and only for a CONSTANT default:ALTERTABLE tasks ADDCOLUMN priority INTEGERDEFAULT3;ALTERTABLE tasks ADDCOLUMN notes TEXT;ALTERTABLE tasks ALTERCOLUMNstatusSETDEFAULT'todo';CREATEINDEX CONCURRENTLY idx_tasks_status ON tasks (status);-- ⚠️ Potentially slow (may lock or rewrite table):ALTERTABLE tasks ALTERCOLUMN price TYPENUMERIC(15,2);-- full rewriteALTERTABLE tasks ADDCOLUMN required TEXTNOTNULL;-- PG < 11: full rewrite-- PG 11+ with a constant DEFAULT (e.g. DEFAULT 3): instant, metadata-only.-- A VOLATILE default (e.g. DEFAULT now() or DEFAULT gen_random_uuid()) still-- forces a full table rewrite even on PG 18 — the instant-default optimization-- only applies when Postgres can prove the same value works for every existing row.-- ✅ Safe pattern for adding a NOT NULL column to a live table:-- Step 1: add nullableALTERTABLE tasks ADDCOLUMN region TEXT;-- Step 2: backfill (in batches for large tables)UPDATE tasks SET region ='us-east'WHERE region ISNULL;-- Step 3: add NOT NULL constraintALTERTABLE tasks ALTERCOLUMN region SETNOTNULL;
-- ❌ The original bad schema (a real example of common mistakes)CREATETABLE user_orders ( id SERIAL,-- should be BIGSERIAL user_email TEXT,-- missing NOT NULL and UNIQUE user_name TEXT,-- should reference a users table product_name TEXT,-- should reference a products table price FLOAT,-- should be NUMERIC order_date TIMESTAMP,-- should be TIMESTAMPTZ is_deleted BOOLEAN-- missing NOT NULL DEFAULT false);
-- ✅ Normalised, correctly typed schemaCREATETABLE users ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, email TEXTNOTNULLUNIQUE, created_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), deleted_at TIMESTAMPTZ
);CREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, price NUMERIC(10,2)NOTNULLCHECK(price >=0));CREATETABLE orders ( id BIGSERIAL PRIMARYKEY, user_id BIGINTNOTNULLREFERENCES users(id)ONDELETERESTRICT, ordered_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), deleted_at TIMESTAMPTZ -- soft delete);CREATETABLE order_items ( id BIGSERIAL PRIMARYKEY, order_id BIGINTNOTNULLREFERENCES orders(id)ONDELETECASCADE, product_id BIGINTNOTNULLREFERENCES products(id)ONDELETERESTRICT, quantity INTEGERNOTNULLCHECK(quantity >0), unit_price NUMERIC(10,2)NOTNULL-- snapshot of price at order time);-- IndexesCREATEINDEX idx_orders_user ON orders (user_id);CREATEINDEX idx_order_items_order ON order_items (order_id);CREATEINDEX idx_order_items_product ON order_items (product_id);CREATEUNIQUEINDEX users_email_active ON users (email)WHERE deleted_at ISNULL;