CMD Guide
HomeDatabasesData Modeling

Best Practices in Data Modeling

Best Practices in Data Modeling

A "best practice" you can't justify is just a slogan. Every rule below reduces to one of two mechanisms: the database keeps redundant structures (indexes, denormalized copies) that speed reads but must be maintained on every write, and it enforces invariants (types, constraints, keys) at exactly one place so they can't be bypassed. Learn the mechanism and each rule stops being a rule you memorize and becomes a cost you can weigh.

1. Index for the reads you actually run — and pay for it on every write

An index is a separate data structure — almost always a B-tree the engine keeps sorted by the indexed column(s), with a pointer back to the row. A lookup on that column becomes an O(log n) tree descent instead of an O(n) full-table scan. That is the whole benefit, and it is large: on a 10-million-row table, a selective indexed lookup touches roughly log₂(10,000,000) ≈ 23 nodes instead of scanning 10,000,000 rows.

The cost is symmetric and unavoidable: because the index is a separate sorted structure, every INSERT, every DELETE, and every UPDATE that changes an indexed column must also insert, remove, or relocate the matching entry in that index — keeping it sorted, sometimes splitting a B-tree page, and writing more to the write-ahead log (WAL). A table with 6 indexes pays roughly 6× the index-maintenance work on every such write (plus the heap write itself).

Traced example. Table orders, 10M rows. Query WHERE customer_id = 42 returns ~50 rows.

Rule: index a column only if (a) a real query filters, joins, or sorts on it, and (b) the column is selective. Indexing a boolean is_active that is 90% true rarely helps — the planner estimates it will read most of the table and scans anyway, so you get the write cost with none of the read benefit. Confirm the index is used with EXPLAIN, and periodically drop indexes that pg_stat_user_indexes reports as never scanned. Never index a write-hot table "just in case."

One INSERT fans out to the heap plus every index B-tree, roughly 4x write work
One INSERT fans out to the heap plus every index B-tree, roughly 4x write work
Normalized data stores a value once; denormalized copies drift when one write is missed
Normalized data stores a value once; denormalized copies drift when one write is missed

2. Normalize by default; denormalize only against proof

Normalization means each fact lives in exactly one place (customer name in customers, referenced by id from orders). The mechanism that makes this safe: with one copy, there is no way for two rows to disagree, so the classic update anomaly — rename a customer and have some orders still show the old name — is structurally impossible. The cost is that reads which need the name must JOIN back to customers.

Denormalization copies the name into every order row. Reads get faster (single-row lookup, no join), but you have manufactured the update anomaly: rename the customer and every copy must be rewritten, or the copies drift (see the diagram — one missed write leaves "Acne" behind). You now own a consistency problem the normalized schema didn't have.

"Judiciously" has a concrete trigger. Denormalize only when all three hold: (1) a specific read query is proven hot — it sits at the top of pg_stat_statements; (2) re-joining is the measured bottleneck for that query, not a guess; and (3) the redundant copy can be kept correct automatically by a trigger, a materialized view, or a scheduled refresh. If you can't name the query and show the numbers, you are denormalizing prematurely.

The named trade-off: normalization = one source of truth, cheap correct writes, join-heavy reads; denormalization = duplicated data, fast reads, and a consistency burden where every write must fan out to the copies or they diverge. Related alternatives that avoid owning the copy: a covering/composite index (let the engine serve the read without a heap trip), a cache with an explicit TTL, or a read replica. Reach for those before you duplicate columns.

3. Right-size data types

The declared type is not cosmetic — it fixes on-disk width, index size, and comparison semantics. BIGINT is 8 bytes, INT 4; on a billion-row table that is ~4 GB of difference in the table and in every index over that column, which is ~4 GB more to cache and scan. Comparison correctness matters just as much: store money as DECIMAL/NUMERIC, never FLOAT0.1 + 0.2 ≠ 0.3 in binary floating point, so a total can be a cent off. Store timestamps as timestamptz, not a string, so range comparisons and sorting are correct rather than lexicographic. Rule: pick the smallest type that fits the real domain with headroom, and a type whose ordering matches how you'll query it.

4. Enforce constraints at the database, not only in the app

A NOT NULL, UNIQUE, CHECK, or FOREIGN KEY constraint is a single source of truth for an invariant, checked no matter which client writes. App-only validation is bypassed by the next service, a batch script, a manual psql session, or a bug in one code path — and once bad data lands, it is expensive to find and repair. The database enforces the rule for all writers, atomically, at the point of write. Rule: every invariant the data must always satisfy (a required field, a valid enum value, a real parent row, a unique email) belongs as a DB constraint; treat app-side checks as a fast-feedback convenience layer on top, not the guarantee.

5. Prefer a surrogate key, but keep the natural key as a constraint

A surrogate key (an auto-generated BIGINT or UUID with no business meaning) is stable: it never changes, so foreign keys pointing at it never need cascading rewrites when a real-world value (email, ISBN, tax id) changes. Natural keys carry meaning but mutate, and a mutating primary key forces updates across every referencing row and index. Rule: use a surrogate as the primary key for stability, and still put a UNIQUE constraint on the natural key so the real-world uniqueness rule is enforced. Caveat: monotonic surrogates (auto-increment, and to a lesser degree time-ordered ids) can become an insert hot-spot at high write rates — a random UUID spreads writes but hurts index locality, so choose with the write pattern in mind.

6. Name consistently

Naming is not aesthetics — it is the interface every future query and migration reads. One convention (e.g. snake_case, singular-vs-plural table names picked once, customer_id meaning the same thing in every table) removes a class of bugs where someone joins userId to user_id or guesses wrong. Rule: decide the convention once, write it down, and let identical concepts share identical names across tables so joins and reasoning are mechanical.

7. Plan for growth before it hurts

Some structural decisions are cheap up front and brutally expensive to retrofit once a table is huge and hot. Partitioning a 500M-row table by created_at lets the planner prune to one month's partition and makes dropping old data an instant partition-detach instead of a giant DELETE that bloats the table and its indexes. Rule: for tables you can forecast will grow without bound (events, logs, orders), decide the partition key and archival policy while the table is still small; for everything else, don't add partitioning machinery you can't yet justify — it has real operational cost.

Pitfalls

Selection & trade-offs (how to decide)

Index or not: add one when a real, selective query needs it and the table is read-heavier on that path than write-heavy; skip it on low-selectivity columns and write-hot tables. Named alternative: a composite/covering index can serve several query shapes with one structure instead of many single-column indexes — fewer writes taxed. Normalize or denormalize: normalize until a specific read is proven hot and the join is the measured cost, then denormalize that path with an automatic sync mechanism — or reach first for the cheaper alternatives (covering index, cache with TTL, read replica) that give the read win without you owning duplicated data.

Takeaways


Re-authored and deepened for this guide, synthesizing Designing Data-Intensive Applications (Kleppmann), Database Internals (Petrov), the PostgreSQL documentation (indexing, EXPLAIN, pg_stat_user_indexes, partitioning), and Use The Index, Luke! (Winand). Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on Best Practices in Data Modeling? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Best Practices in Data Modeling** (Databases) and want to truly understand it. Explain Best Practices in Data Modeling from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Best Practices in Data Modeling** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Best Practices in Data Modeling** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Best Practices in Data Modeling** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes