Module P-4·24 min read

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

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

DecisionRecommended ChoiceWhy
MoneyNUMERIC(15,2) or BIGINT (cents)Exact arithmetic; FLOAT loses precision
TimestampsTIMESTAMPTZStores UTC; survives timezone changes
String enumsTEXT NOT NULL CHECK (... IN ...)Easy to add values; no type migration needed
IDsBIGSERIAL or UUID v7BIGSERIAL for internal; UUID v7 for public-facing
Audit fieldscreated_at, updated_at on every tableEssential for debugging and data quality
Soft deletedeleted_at TIMESTAMPTZPreserves history; use partial unique indexes
NOT NULL new columnsAdd nullable → backfill → add NOT NULLSafe on live tables
updated_at auto-updateTrigger calling SET NEW.updated_at = NOW()Consistent across all update paths
NormalisationDefault to normalised; denormalise only with dataPrevents 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.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.