Normalisation, denormalisation trade-offs, correct data types for money and time, soft deletes, and schema migration tools.
P-4 — Schema Design for Real Applications
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
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
orderstable should store the price at the time of purchase, not reference the currentproducts.price— prices change, but the order price should not - Audit logs: copy the full state of a row when it changes — redundancy is intentional
Choosing the Right Data Types
Money: NUMERIC, Not FLOAT
Timestamps: TIMESTAMPTZ, Always
Enumerations: TEXT with CHECK vs ENUM type
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
UUID vs BIGSERIAL
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:
updated_at does not update automatically — you must update it in your queries or use a trigger:
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.
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)
Multi-Tenancy — Isolating Customer Data
For SaaS applications serving multiple organisations:
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
node-pg-migrate (Node.js)
Prisma Migrate (Node.js / TypeScript)
Safe schema changes in production
Not all ALTER TABLE operations are instant. Know which are dangerous:
Practical Exercise: Redesign a Real Schema
Start with a poorly designed schema and improve it:
Redesign it:
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 →
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.
Sign in & RegisterDiscussion
0Join the discussion