Database normalization and schema design patterns

Maria Garcia Feb 2026
2 tabs
-- Unnormalized (0NF): Repeating groups
CREATE TABLE orders_bad (
  order_id INT,
  customer_name VARCHAR(100),
  customer_email VARCHAR(100),
  product1 VARCHAR(100),
  product2 VARCHAR(100),
  product3 VARCHAR(100)
);
-- Problem: Fixed number of products, redundant customer data

-- First Normal Form (1NF): Atomic values
CREATE TABLE orders_1nf (
  order_id INT PRIMARY KEY,
  customer_name VARCHAR(100),
  customer_email VARCHAR(100),
  product VARCHAR(100)
);
-- Better: One product per row, but still redundant customer data

-- Second Normal Form (2NF): No partial dependencies
CREATE TABLE orders (
  order_id SERIAL PRIMARY KEY,
  customer_id INT REFERENCES customers(id),
  order_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100),
  email VARCHAR(100) UNIQUE
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INT REFERENCES orders(order_id),
  product_id INT REFERENCES products(id),
  quantity INT,
  price DECIMAL(10,2)
);
-- Better: Customer data in separate table

-- Third Normal Form (3NF): No transitive dependencies
CREATE TABLE products (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200),
  category_id INT REFERENCES categories(id),
  -- Don't store category_name here (transitive dependency)
  price DECIMAL(10,2)
);

CREATE TABLE categories (
  id SERIAL PRIMARY KEY,
  name VARCHAR(100),
  description TEXT
);
-- Proper 3NF: Category details in separate table

-- Denormalization for performance
CREATE TABLE products_denormalized (
  id SERIAL PRIMARY KEY,
  name VARCHAR(200),
  category_id INT REFERENCES categories(id),
  category_name VARCHAR(100),  -- Denormalized!
  price DECIMAL(10,2),
  total_orders INT DEFAULT 0,   -- Cached aggregate
  last_ordered_at TIMESTAMP
);
-- Trade: Faster reads, more storage, must maintain consistency

-- Maintain denormalized data with triggers
CREATE OR REPLACE FUNCTION update_product_stats()
RETURNS TRIGGER AS $$
BEGIN
  UPDATE products_denormalized
  SET total_orders = total_orders + 1,
      last_ordered_at = CURRENT_TIMESTAMP
  WHERE id = NEW.product_id;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_update_product_stats
AFTER INSERT ON order_items
FOR EACH ROW
EXECUTE FUNCTION update_product_stats();
2 files · sql Explain with highlit

Normalization eliminates redundancy and anomalies. 1NF requires atomic values—no arrays in columns. 2NF eliminates partial dependencies—all non-key columns depend on entire primary key. 3NF removes transitive dependencies—non-key columns don't depend on other non-key columns. I normalize to 3NF for most applications. Denormalization trades redundancy for performance—materialized aggregates, caching. Star schema suits data warehouses—fact tables with dimension references. Snowflake schema normalizes dimensions. Understanding when to denormalize is key—read-heavy vs write-heavy workloads. EAV (Entity-Attribute-Value) is usually anti-pattern—use JSONB instead. Proper schema design prevents future pain. Balance normalization with query performance requirements.