Data Modeling Patterns
Normalization Review
Section titled “Normalization Review”When to Stop Normalizing
Section titled “When to Stop Normalizing”Normalization eliminates redundancy and update anomalies, but there is a point of diminishing Returns. The decision of when to stop depends on your read/write ratio, performance requirements, And complexity tolerance.
| Normal Form | Eliminates | Practical Impact |
|---|---|---|
| 1NF | Repeating groups, non-atomic values | Foundation; every table should be in 1NF |
| 2NF | Partial dependencies on composite keys | Eliminates redundant data in composite PKs |
| 3NF | Transitive dependencies (A → B → C) | Most OLTP schemas stop here |
| BCNF | All candidate keys fully determined | Slight refinement over 3NF |
| 4NF | Multi-valued dependencies | Rarely needed in practice |
| 5NF | Join dependencies | Theoretical; almost never practical |
3NF vs Denormalization
Section titled “3NF vs Denormalization”-- 3NF: normalized (three tables)CREATE TABLE customers ( customer_id SERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL UNIQUE);
CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(customer_id), total NUMERIC(10,2) NOT NULL, status TEXT NOT NULL);
CREATE TABLE order_items ( item_id SERIAL PRIMARY KEY, order_id INTEGER NOT NULL REFERENCES orders(order_id), product_id INTEGER NOT NULL, quantity INTEGER NOT NULL, unit_price NUMERIC(10,2) NOT NULL);
-- Denormalized for read-heavy dashboards (materialized view or cache table):CREATE TABLE order_summary ( order_id INTEGER PRIMARY KEY, customer_name TEXT NOT NULL, customer_email TEXT NOT NULL, total NUMERIC(10,2) NOT NULL, status TEXT NOT NULL, item_count INTEGER NOT NULL, created_at TIMESTAMPTZ NOT NULL);When to Denormalize
Section titled “When to Denormalize”| Scenario | Recommendation |
|---|---|
| OLTP with frequent writes | Normalize (3NF) |
| Read-heavy reporting dashboards | Denormalize (materialized view) |
| High-frequency reads with infrequent updates | Cached/precomputed column |
| Data warehousing / OLAP | Star/snowflake schema |
| Real-time aggregation requirements | Precomputed aggregates |
Denormalization Patterns
Section titled “Denormalization Patterns”Cached Columns
Section titled “Cached Columns”Store computed values directly in the table to avoid expensive joins or calculations:
CREATE TABLE orders ( order_id SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(customer_id), subtotal NUMERIC(10,2) NOT NULL, tax NUMERIC(10,2) NOT NULL, total NUMERIC(10,2) GENERATED ALWAYS AS (subtotal + tax) STORED, item_count INTEGER NOT NULL DEFAULT 0);
-- Update item_count on every order_items change (trigger or application logic)CREATE OR REPLACE FUNCTION update_order_item_count()RETURNS TRIGGER AS $$BEGIN IF TG_OP = "INSERT' THEN UPDATE orders SET item_count = item_count + NEW.quantity WHERE order_id = NEW.order_id; ELSIF TG_OP = 'DELETE' THEN UPDATE orders SET item_count = item_count - OLD.quantity WHERE order_id = OLD.order_id; END IF; RETURN NULL;END;$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_order_items_countAFTER INSERT OR DELETE ON order_itemsFOR EACH ROW EXECUTE FUNCTION update_order_item_count();Precomputed Aggregates
Section titled “Precomputed Aggregates”CREATE TABLE customer_stats ( customer_id INTEGER PRIMARY KEY REFERENCES customers(customer_id), total_orders INTEGER NOT NULL DEFAULT 0, total_spent NUMERIC(12,2) NOT NULL DEFAULT 0, avg_order_value NUMERIC(10,2), last_order_at TIMESTAMPTZ, updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Refresh periodically or on order eventsINSERT INTO customer_stats (customer_id, total_orders, total_spent, avg_order_value, last_order_at)SELECT customer_id, COUNT(*), SUM(total), AVG(total), MAX(created_at)FROM ordersGROUP BY customer_idON CONFLICT (customer_id) DO UPDATE SET total_orders = EXCLUDED.total_orders, total_spent = EXCLUDED.total_spent, avg_order_value = EXCLUDED.avg_order_value, last_order_at = EXCLUDED.last_order_at, updated_at = NOW();Temporal Data Modeling
Section titled “Temporal Data Modeling”Valid-Time (Business Time)
Section titled “Valid-Time (Business Time)”Records when a fact was true in the real world:
CREATE TABLE employee_salary_history ( history_id SERIAL PRIMARY KEY, employee_id INTEGER NOT NULL REFERENCES employees(employee_id), salary NUMERIC(10,2) NOT NULL, valid_from DATE NOT NULL, valid_to DATE NOT NULL, CONSTRAINT no_overlaps EXCLUDE USING gist ( employee_id WITH =, daterange(valid_from, valid_to, '[]') WITH && ), CONSTRAINT valid_range CHECK (valid_from < valid_to));Transaction-Time (System Time)
Section titled “Transaction-Time (System Time)”Records when a fact was recorded in the database (append-only, never modified):
CREATE TABLE employee_salary_tx ( sys_start TIMESTAMPTZ NOT NULL DEFAULT NOW(), sys_end TIMESTAMPTZ NOT NULL DEFAULT '9999-12-31', employee_id INTEGER NOT NULL, salary NUMERIC(10,2) NOT NULL, PRIMARY KEY (employee_id, sys_start));
-- Current record: sys_end = '9999-12-31'-- Historical record: sys_end = actual end timestampSlowly Changing Dimensions (SCD Types)
Section titled “Slowly Changing Dimensions (SCD Types)”SCD Type 1 (Overwrite): Directly update the value. No history retained.
-- No special structure neededUPDATE products SET category = 'Electronics' WHERE product_id = 42;SCD Type 2 (Add Row): Insert a new row with version tracking:
CREATE TABLE products_scd2 ( product_sk SERIAL PRIMARY KEY, product_id INTEGER NOT NULL, name TEXT NOT NULL, category TEXT NOT NULL, valid_from DATE NOT NULL, valid_to DATE NOT NULL, is_current BOOLEAN NOT NULL DEFAULT TRUE);
-- On change: close current row, insert new rowUPDATE products_scd2 SET valid_to = CURRENT_DATE, is_current = FALSEWHERE product_id = 42 AND is_current = TRUE;
INSERT INTO products_scd2 (product_id, name, category, valid_from, valid_to, is_current)VALUES (42, 'Widget Pro', 'Electronics', CURRENT_DATE, '9999-12-31', TRUE);SCD Type 3 (Add Column): Track previous value in a separate column:
CREATE TABLE products_scd3 ( product_id INTEGER PRIMARY KEY, name TEXT NOT NULL, current_category TEXT NOT NULL, previous_category TEXT);
-- On change: move current to previousUPDATE products_scd3SET previous_category = current_category, current_category = 'Electronics'WHERE product_id = 42;| SCD Type | History Retained | Storage | Complexity | Use Case |
|---|---|---|---|---|
| Type 1 | None | Minimal | Low | Corrections, insignificant changes |
| Type 2 | Full | High | High | Audit trails, analytics |
| Type 3 | One previous | Low | Medium | Limited history needed |
Hierarchical Data
Section titled “Hierarchical Data”Adjacency List
Section titled “Adjacency List”Each row stores a reference to its parent:
CREATE TABLE categories ( category_id SERIAL PRIMARY KEY, name TEXT NOT NULL, parent_id INTEGER REFERENCES categories(category_id));
-- Query: find all ancestors of a categoryWITH RECURSIVE ancestors AS ( SELECT category_id, name, parent_id, 1 AS depth FROM categories WHERE category_id = 42 UNION ALL SELECT c.category_id, c.name, c.parent_id, a.depth + 1 FROM categories c JOIN ancestors a ON c.category_id = a.parent_id)SELECT * FROM ancestors;Nested Sets
Section titled “Nested Sets”Each node stores left and right bounds. The entire tree is encoded in a single table with no Recursion needed for many queries:
CREATE TABLE categories_ns ( category_id SERIAL PRIMARY KEY, name TEXT NOT NULL, lft INTEGER NOT NULL, rgt INTEGER NOT NULL);
-- Get all descendants (no recursion needed):SELECT * FROM categories_ns WHERE lft BETWEEN 5 AND 12 ORDER BY lft;
-- Get all ancestors:SELECT * FROM categories_ns WHERE lft < 5 AND rgt > 12 ORDER BY lft;
-- Count descendants:(rgt - lft - 1) / 2Path Enumeration
Section titled “Path Enumeration”Store the full path from root to each node:
CREATE TABLE categories_path ( category_id SERIAL PRIMARY KEY, name TEXT NOT NULL, path TEXT NOT NULL -- e.g., '/1/5/42/');
-- All descendants of category 5:SELECT * FROM categories_path WHERE path LIKE '/1/5/%';
-- All ancestors of category 42:-- Extract path segments: /1/5/42/ → ancestors are 1 and 5SELECT * FROM categories_pathWHERE category_id = ANY( SELECT unnest(string_to_array('/1/5/42/', '/')::INTEGER[]))AND category_id IS NOT NULL;Closure Table
Section titled “Closure Table”Store all ancestor-descendant pairs explicitly:
CREATE TABLE tree_nodes ( node_id SERIAL PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE tree_closure ( ancestor_id INTEGER NOT NULL REFERENCES tree_nodes(node_id), descendant_id INTEGER NOT NULL REFERENCES tree_nodes(node_id), depth INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (ancestor_id, descendant_id));
-- Insert a new node as child of node 5:-- 1. Insert the nodeINSERT INTO tree_nodes (name) VALUES ('New Node') RETURNING node_id;-- 2. Insert self-reference and all ancestor pathsINSERT INTO tree_closure (ancestor_id, descendant_id, depth)SELECT ancestor_id, NEW.node_id, depth + 1FROM tree_closure WHERE descendant_id = 5UNION ALLVALUES (NEW.node_id, NEW.node_id, 0);Comparison
Section titled “Comparison”| Pattern | Insert | Update (move subtree) | Read ancestors | Read descendants | Storage |
|---|---|---|---|---|---|
| Adjacency List | O(1) | O(1) | O(depth) CTE | O(depth) CTE | Minimal |
| Nested Sets | O(n) | O(n) | O(1) | O(1) | Minimal |
| Path Enumeration | O(1) | O(n) | O(1) | O(1) LIKE | Path column |
| Closure Table | O(n) | O(n*depth) | O(depth) | O(depth) | O(n^2) |
Polymorphic Associations
Section titled “Polymorphic Associations”Shared Table (Single Table Inheritance)
Section titled “Shared Table (Single Table Inheritance)”All types share one table with a type discriminator:
CREATE TABLE payments ( id SERIAL PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('credit_card', 'paypal', 'bank_transfer')), amount NUMERIC(10,2) NOT NULL, -- Credit card fields card_last_four CHAR(4), -- PayPal fields paypal_email TEXT, -- Bank transfer fields bank_account TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
-- Query specific typeSELECT * FROM payments WHERE type = 'credit_card' AND card_last_four IS NOT NULL;Class Table Inheritance (One Table per Type)
Section titled “Class Table Inheritance (One Table per Type)”CREATE TABLE payments ( id SERIAL PRIMARY KEY, type TEXT NOT NULL, amount NUMERIC(10,2) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
CREATE TABLE credit_card_payments ( payment_id INTEGER PRIMARY KEY REFERENCES payments(id), card_last_four CHAR(4) NOT NULL, expiry_month INTEGER NOT NULL, expiry_year INTEGER NOT NULL);
CREATE TABLE paypal_payments ( payment_id INTEGER PRIMARY KEY REFERENCES payments(id), paypal_email TEXT NOT NULL);
-- Query with JOINSELECT p.*, c.card_last_fourFROM payments pJOIN credit_card_payments c ON p.id = c.payment_idWHERE p.type = 'credit_card';JSON Columns
Section titled “JSON Columns”Store type-specific attributes as JSON:
CREATE TABLE payments ( id SERIAL PRIMARY KEY, type TEXT NOT NULL, amount NUMERIC(10,2) NOT NULL, metadata JSONB NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW());
INSERT INTO payments (type, amount, metadata)VALUES ('credit_card', 100.00, '{"card_last_four": "1234", "expiry": "12/25"}');
INSERT INTO payments (type, amount, metadata)VALUES ('paypal', 50.00, '{"paypal_email": "user@example.com"}');
-- Query JSONB fieldsSELECT * FROM payments WHERE metadata ->> 'card_last_four' = '1234';CREATE INDEX idx_payments_metadata ON payments USING GIN (metadata);| Approach | Pros | Cons |
|---|---|---|
| Shared table | Simple queries, no JOINs | Many NULL columns, weak typing |
| Class table | Strong typing, no wasted space | JOINs required, complex queries |
| JSON columns | Flexible, schemaless fields | No foreign keys, no type checking |
Many-to-Many Relationships
Section titled “Many-to-Many Relationships”Join Table with Attributes
Section titled “Join Table with Attributes”CREATE TABLE order_items ( order_id INTEGER NOT NULL REFERENCES orders(order_id), product_id INTEGER NOT NULL REFERENCES products(product_id), quantity INTEGER NOT NULL CHECK (quantity > 0), unit_price NUMERIC(10,2) NOT NULL, discount NUMERIC(5,2) NOT NULL DEFAULT 0, line_total NUMERIC(10,2) GENERATED ALWAYS AS (quantity * unit_price * (1 - discount / 100)) STORED, PRIMARY KEY (order_id, product_id));Many-to-Many with Metadata
Section titled “Many-to-Many with Metadata”-- User follows user (social graph)CREATE TABLE follows ( follower_id INTEGER NOT NULL REFERENCES users(user_id), followee_id INTEGER NOT NULL REFERENCES users(user_id), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), PRIMARY KEY (follower_id, followee_id), CHECK (follower_id != followee_id));
CREATE INDEX idx_follows_followee ON follows (followee_id);Soft Delete vs Hard Delete
Section titled “Soft Delete vs Hard Delete”-- Soft delete: mark as deleted but keep the rowCREATE TABLE users ( user_id SERIAL PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, deleted_at TIMESTAMPTZ, -- NULL = active, non-NULL = deleted CONSTRAINT valid_email CHECK ( deleted_at IS NULL OR email LIKE '%_deleted_%' ));
-- All queries must filter out soft-deleted rowsSELECT * FROM users WHERE deleted_at IS NULL;
-- Hard delete: permanently remove the rowDELETE FROM users WHERE user_id = 42;| Aspect | Soft Delete | Hard Delete |
|---|---|---|
| Recovery | Reversible (set deleted_at=NULL) | Not reversible |
| Storage | Rows accumulate | Space freed |
| Query perf | All queries need WHERE clause | No filter overhead |
| Uniqueness | Must handle deleted emails | Natural uniqueness |
| Referential | FK constraints still apply | CASCADE removes dependents |