Data Modeling Best Practices Cheat Sheet
Covers practical guidelines for choosing keys, normalization level, indexing, and schema evolution when designing production database schemas.
Core Principles
Guidelines that hold up across most relational schemas.
- Model access patterns first- Design tables around how the application actually queries data, not just the abstract entity relationships
- Choose the right key type- Use surrogate integer/UUID keys for internal joins; avoid using mutable business data (email, SSN) as a primary key
- Normalize until it hurts, denormalize until it works- Start normalized (3NF) for correctness, then selectively denormalize hot read paths once you've measured a real bottleneck
- Enforce constraints in the database- Use NOT NULL, UNIQUE, CHECK, and foreign keys — application-only validation gets bypassed by scripts, migrations, and bugs
- Use appropriate data types- Store dates as DATE/TIMESTAMP not strings, money as NUMERIC/DECIMAL not FLOAT, and booleans as BOOLEAN not 0/1 integers
Well-Modeled Schema
Constraints and types that enforce correctness at write time.
CREATE TABLE accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email CITEXT UNIQUE NOT NULL, balance_cents BIGINT NOT NULL DEFAULT 0 CHECK (balance_cents >= 0), status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'closed')), created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE transactions ( id BIGSERIAL PRIMARY KEY, account_id UUID NOT NULL REFERENCES accounts(id), amount_cents BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE INDEX idx_transactions_account_created ON transactions (account_id, created_at DESC);
Common Anti-Patterns
Modeling mistakes that create pain later.
- Entity-Attribute-Value (EAV)- Storing arbitrary key/value rows instead of real columns; kills query performance and type safety, avoid unless truly unavoidable
- God table- One giant table with dozens of nullable columns for many unrelated purposes; split into focused, properly related tables
- Storing money as float- Floating-point rounding errors corrupt financial calculations; use DECIMAL/NUMERIC or integer cents
- No foreign keys 'for performance'- Skipping FK constraints to save a few cycles usually just relocates data-integrity bugs into production incident reports
- Polymorphic associations without a discriminator- A column that can reference different tables depending on context breaks referential integrity; use separate join tables per type instead
JSON Column vs Normalized Table
When a JSONB column is appropriate versus a real table.
-- OK: JSONB for genuinely variable, rarely-queried attributesCREATE TABLE products ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, attributes JSONB -- e.g., {"color": "red", "size": "M"} varies per category);-- Better: normalize anything you filter, sort, or join on frequentlyCREATE TABLE product_prices ( product_id INT REFERENCES products(id), currency CHAR(3) NOT NULL, amount_cents BIGINT NOT NULL, PRIMARY KEY (product_id, currency));-- Index into JSONB only when necessaryCREATE INDEX idx_products_color ON products ((attributes->>'color'));
Temporal / Bitemporal Modeling
Track both when a fact was true in the real world and when the system recorded it, enabling point-in-time queries and audit.
CREATE TABLE price_history ( product_id INT NOT NULL REFERENCES products(id), price_cents BIGINT NOT NULL, -- valid time: when this price was actually in effect valid_from TIMESTAMPTZ NOT NULL, valid_to TIMESTAMPTZ NOT NULL DEFAULT 'infinity', -- transaction time: when the row was recorded/corrected recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), EXCLUDE USING gist ( product_id WITH =, tstzrange(valid_from, valid_to) WITH && ));-- Query the price that was effective at a past point in timeSELECT price_cents FROM price_historyWHERE product_id = 7 AND valid_from <= '2026-03-01' AND valid_to > '2026-03-01';
Modeling Polymorphic Associations Correctly
Replace a single nullable-type foreign key with per-type join tables to preserve referential integrity.
-- Anti-pattern: commentable_type + commentable_id with no real FK-- CREATE TABLE comments (id SERIAL, commentable_type TEXT, commentable_id INT);-- Better: exclusive-arc join tables, each with a real foreign keyCREATE TABLE comments ( id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now());CREATE TABLE post_comments ( comment_id BIGINT PRIMARY KEY REFERENCES comments(id), post_id INT NOT NULL REFERENCES posts(id));CREATE TABLE photo_comments ( comment_id BIGINT PRIMARY KEY REFERENCES comments(id), photo_id INT NOT NULL REFERENCES photos(id));-- Each join table enforces a real, indexable, integrity-checked FK-- instead of an application-level lookup by string type
Beyond 3NF
Higher normal forms and when the extra rigor actually pays off.
- BCNF (Boyce-Codd)- Every determinant must be a candidate key; fixes edge cases 3NF misses where a non-key attribute determines part of a composite key
- 4NF- Eliminates multi-valued dependencies — e.g., a table mixing a person's independent skills and independent languages should split into two tables
- 5NF (PJ/NF)- Guards against join dependency anomalies in tables representing many-to-many-to-many relationships; rarely needed outside modeling tools/compliance systems
- When to stop at 3NF- Most OLTP schemas get diminishing returns past 3NF/BCNF; chasing 4NF/5NF everywhere adds join complexity for anomalies that may never occur in your data
- Denormalization as a deliberate trade- Materialized aggregates, summary tables, and read replicas with denormalized joins are valid once you can prove the write-side integrity is protected elsewhere (triggers, application invariants, event sourcing)
Modeling Hierarchies: Closure Table
Store every ancestor-descendant pair to make subtree queries O(1) joins instead of recursive CTEs.
CREATE TABLE categories ( id SERIAL PRIMARY KEY, name TEXT NOT NULL);CREATE TABLE category_closure ( ancestor_id INT NOT NULL REFERENCES categories(id), descendant_id INT NOT NULL REFERENCES categories(id), depth INT NOT NULL, PRIMARY KEY (ancestor_id, descendant_id));-- Every node has a depth-0 self-reference row-- Inserting a child of category 5 under root 1 requires copying-- all of 5's ancestor rows with depth + 1-- Fetch entire subtree of category 3 in one indexed join, no recursionSELECT c.* FROM categories cJOIN category_closure cc ON cc.descendant_id = c.idWHERE cc.ancestor_id = 3;
Schema Evolution & Migration Safety
Practices for changing a live production schema without downtime.
- Expand-contract pattern- Add new columns/tables (expand), backfill and dual-write, cut reads over, then drop the old columns (contract) — never rename/drop in a single deploy
- Avoid long table locks- Adding a NOT NULL column with a default on Postgres < 11 rewrites the whole table; use nullable + backfill + constraint-add (which is fast, metadata-only) instead
- Backward-compatible reads first- Deploy code that can read both old and new shapes before the migration runs, so a rollback of either side doesn't break the other
- Index creation online- Use CREATE INDEX CONCURRENTLY (Postgres) or equivalent to avoid blocking writes during index builds on large tables
- Versioned event/JSON schemas- When persisting JSONB payloads or event schemas, embed a schema_version field so old rows can be migrated lazily on read instead of requiring a big-bang rewrite
Treat every 'just add a JSON blob column' decision as normalization debt you're taking on — it's fine for truly sparse/variable attributes, but the moment you're filtering, joining, or aggregating on a JSON field regularly, promote it to a real column before query performance and data quality both degrade.