
Designing a Production-Grade Relational Database Schema
A pragmatic walkthrough of normalisation, indexing, and migration strategy for teams shipping real workloads.
Database schema decisions outlive most of the code that surrounds them. Once a column is in production with millions of rows behind it, every change becomes a migration, a deploy plan, and a rollback drill. The schemas that age well are the ones that were designed with growth, observability, and operational pain in mind from day one.
Start by enumerating the entities and the questions the application will ask of them. Normalise far enough to remove update anomalies, but stop the moment a denormalisation buys you a clear, measurable read win. The 3NF baseline is a safety net, not a destination — pragmatic systems pair it with carefully chosen redundant columns and materialised projections.
Indexes are not free. Each index makes writes slower, occupies disk and memory, and complicates query planning. Track the working set of your hot indexes, watch for unused ones in pg_stat_user_indexes or its equivalent, and treat composite index ordering as a first-class design choice rather than an afterthought.
Migrations are part of the schema. Adopt expand-then-contract patterns: deploy code that tolerates both the old and the new shape, backfill in batches under load, then remove the legacy column in a follow-up release. Never couple a destructive migration to a single deploy — that is how outages are written.
Finally, instrument everything. Slow query logs, per-query latency histograms, and table-level write rates tell you when a schema is starting to hurt. The earlier those signals reach the team, the cheaper the eventual refactor will be.