Module P-4·17 min read

Normalisation, denormalisation trade-offs, correct data types for money and time, soft deletes, and schema migration tools.

Written by Jatin Jain Saraf · Architect of SupraScan

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

-- ❌ DENORMALISED: customer city repeated in every order
CREATE TABLE orders_bad (
  id            BIGSERIAL PRIMARY KEY,
  customer_name TEXT NOT NULL,
  customer_city TEXT NOT NULL,   -- repeated for every order
  product       TEXT NOT NULL,
  amount        NUMERIC NOT NULL
);
 
-- 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 record
CREATE TABLE customers (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  city TEXT NOT NULL
);
 
CREATE TABLE orders (
  id          BIGSERIAL PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customers(id),
  product     TEXT NOT NULL,
  amount      NUMERIC NOT NULL
);
-- Alice's city is updated in one place — every order reflects it automatically

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
-- CORRECT: storing price at order time, not referencing current price
CREATE TABLE order_items (
  id          BIGSERIAL PRIMARY KEY,
  order_id    BIGINT NOT NULL REFERENCES orders(id),
  product_id  BIGINT NOT NULL REFERENCES products(id),
  quantity    INTEGER NOT NULL,
  unit_price  NUMERIC(10,2) NOT NULL  -- snapshot of price when ordered
  -- NOT a foreign key to products.price — that would reflect future prices
);

Choosing the Right Data Types

Money: NUMERIC, Not FLOAT

-- ❌ WRONG: floating-point arithmetic loses precision
CREATE TABLE invoices_bad (total FLOAT);
INSERT INTO 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 exact
CREATE TABLE invoices (total NUMERIC(15, 2));
INSERT INTO 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 entirely
CREATE TABLE orders (total_cents BIGINT NOT NULL);

Timestamps: TIMESTAMPTZ, Always

-- ❌ WRONG: no timezone context
created_at TIMESTAMP   -- is this UTC? server local time? user local time?
 
-- ✅ CORRECT: timezone-aware
created_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

Enumerations: TEXT with CHECK vs ENUM type

-- Option 1: TEXT with CHECK constraint (recommended for most cases)
status TEXT NOT NULL CHECK (status IN ('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 type
CREATE TYPE user_status AS ENUM ('pending', 'active', 'suspended', 'deleted');
CREATE TABLE users (status user_status NOT NULL);
-- 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

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

-- ✅ CORRECT: explicit NOT NULL with a sensible default
is_published BOOLEAN NOT NULL DEFAULT false
is_verified  BOOLEAN NOT NULL DEFAULT false
 
-- ❌ 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

UUID vs BIGSERIAL

-- BIGSERIAL: auto-incrementing, sequential, fast for indexes
id BIGSERIAL PRIMARY KEY
-- ✅ 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 unique
id UUID PRIMARY KEY DEFAULT 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' extension
id UUID PRIMARY KEY DEFAULT 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

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

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