Denormalization Best Practices
Document invariants denorm columns must preserve. Denormalize only with evidence, ownership, and drift detection - not because joins feel slow in development.
Search across all documentation pages
Document invariants denorm columns must preserve. Denormalize only with evidence, ownership, and drift detection - not because joins feel slow in development.
pg_stat_statements and EXPLAIN (ANALYZE, BUFFERS). No denorm without measured hot queries.max_staleness SLA.COMMENT ON COLUMN for every denorm field. State the invariant in plain language and owner team.REFRESH MATERIALIZED VIEW schedule with on-call owner.schema_version inside snapshot blobs.NOT NULL on snapshot columns filled at insert. Partial NULL snapshots break finance and support tooling.REFRESH CONCURRENTLY. Concurrent MV refresh fails without a unique index.tenant_id in the invariant. Cross-tenant leakage risk rises.Snapshots freeze values at an event (price at purchase). Mirrors try to track live dimension data and need refresh when the source changes.
COMMENT ON COLUMN order_items.unit_price IS
'SNAPSHOT invariant: unit price at insert; source products.price';Mirrors: nightly or hourly depending on SLA. Snapshots: only when business rules retroactively change source data.
Historical snapshots (unit_price, tax_rate_at_sale) are business requirements, not optional performance tricks.
Treating mirror columns as snapshots - reports show stale customer emails forever with no refresh job.
Prefer MVs when many queries share the same aggregation. Prefer column denorm when a single row needs embedded snapshot fields in OLTP writes.
Maintain a denorm_registry table or grep COMMENT ON COLUMN for SNAPSHOT / MIRROR tags in migration files.
Only for derivable rules (line_total = quantity * unit_price). Cross-table mirrors need triggers or jobs.
When EXPLAIN shows normalized path meets SLO after index or hardware improvements for two consecutive quarters.
Warehouses denormalize by design. OLTP denorm still needs invariants because apps write the same rows transactions depend on.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 19, 2026