Claude-initial-setup schema-design-guide

install
source · Clone the upstream repo
git clone https://github.com/VersoXBT/claude-initial-setup
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/VersoXBT/claude-initial-setup "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/database/schema-design-guide" ~/.claude/skills/versoxbt-claude-initial-setup-schema-design-guide && rm -rf "$T"
manifest: skills/database/schema-design-guide/SKILL.md
source content

Schema Design Guide

Design well-structured relational database schemas with proper normalization, strategic indexing, referential integrity, and clear naming conventions.

When to Use

  • User designs new database tables
  • User asks about normalization or denormalization
  • User needs help with indexing strategy
  • User defines relationships between tables
  • User asks about naming conventions for database objects

Core Patterns

Normalization (1NF through 3NF)

Apply normalization to eliminate redundancy, then selectively denormalize for performance.

-- BAD: Unnormalized (repeating groups, redundant data)
CREATE TABLE orders_bad (
  id SERIAL PRIMARY KEY,
  customer_name VARCHAR(255),
  customer_email VARCHAR(255),
  product1_name VARCHAR(255),
  product1_price DECIMAL(10,2),
  product2_name VARCHAR(255),
  product2_price DECIMAL(10,2)
);

-- GOOD: Normalized to 3NF
CREATE TABLE customers (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  email VARCHAR(255) NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE products (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  price DECIMAL(10,2) NOT NULL CHECK (price >= 0),
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE orders (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id BIGINT NOT NULL REFERENCES customers(id),
  status VARCHAR(50) NOT NULL DEFAULT 'pending'
    CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
  ordered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE order_items (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
  product_id BIGINT NOT NULL REFERENCES products(id),
  quantity INT NOT NULL CHECK (quantity > 0),
  unit_price DECIMAL(10,2) NOT NULL CHECK (unit_price >= 0),
  UNIQUE (order_id, product_id)
);

Indexing Strategy

Index columns used in WHERE, JOIN, and ORDER BY clauses. Prefer composite indexes that match query patterns.

-- Index for frequent queries
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_status_ordered_at ON orders(status, ordered_at DESC);
CREATE INDEX idx_order_items_order_id ON order_items(order_id);

-- Partial index for active records only
CREATE INDEX idx_orders_pending ON orders(ordered_at DESC)
  WHERE status = 'pending';

-- Covering index to avoid table lookups
CREATE INDEX idx_products_name_price ON products(name, price);

-- Expression index for case-insensitive search
CREATE INDEX idx_customers_email_lower ON customers(LOWER(email));

-- GIN index for full-text search
CREATE INDEX idx_products_name_search ON products USING gin(to_tsvector('english', name));

Constraints and Data Integrity

Use database-level constraints as the last line of defense. Application validation can have bugs; constraints cannot be bypassed.

CREATE TABLE accounts (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  email VARCHAR(255) NOT NULL,
  balance DECIMAL(12,2) NOT NULL DEFAULT 0 CHECK (balance >= 0),
  account_type VARCHAR(20) NOT NULL CHECK (account_type IN ('checking', 'savings')),
  opened_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  closed_at TIMESTAMPTZ,

  -- Ensure closed_at is after opened_at
  CONSTRAINT valid_dates CHECK (closed_at IS NULL OR closed_at > opened_at),

  -- Unique email per account type
  CONSTRAINT unique_email_per_type UNIQUE (email, account_type)
);

-- Foreign key with appropriate action
CREATE TABLE transactions (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  account_id BIGINT NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT,
  amount DECIMAL(12,2) NOT NULL,
  description TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Naming Conventions

Consistent naming makes schemas self-documenting.

-- Tables: plural, snake_case
CREATE TABLE user_profiles (...);
CREATE TABLE order_items (...);

-- Columns: singular, snake_case
-- Primary key: id
-- Foreign key: referenced_table_singular_id
CREATE TABLE comments (
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id BIGINT NOT NULL REFERENCES users(id),
  post_id BIGINT NOT NULL REFERENCES posts(id),
  body TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Indexes: idx_table_columns
CREATE INDEX idx_comments_user_id ON comments(user_id);
CREATE INDEX idx_comments_post_id_created_at ON comments(post_id, created_at DESC);

-- Constraints: chk_table_description, uq_table_columns
ALTER TABLE comments ADD CONSTRAINT chk_comments_body_length CHECK (length(body) > 0);

Strategic Denormalization

Denormalize only when you have measured performance problems and normalization is the bottleneck.

-- Denormalized: Store computed totals for read-heavy dashboards
CREATE TABLE order_summaries (
  order_id BIGINT PRIMARY KEY REFERENCES orders(id),
  item_count INT NOT NULL DEFAULT 0,
  total_amount DECIMAL(12,2) NOT NULL DEFAULT 0,
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Keep denormalized data in sync with a trigger
CREATE OR REPLACE FUNCTION update_order_summary()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO order_summaries (order_id, item_count, total_amount, updated_at)
  SELECT
    COALESCE(NEW.order_id, OLD.order_id),
    COUNT(*),
    COALESCE(SUM(quantity * unit_price), 0),
    now()
  FROM order_items
  WHERE order_id = COALESCE(NEW.order_id, OLD.order_id)
  ON CONFLICT (order_id)
  DO UPDATE SET
    item_count = EXCLUDED.item_count,
    total_amount = EXCLUDED.total_amount,
    updated_at = EXCLUDED.updated_at;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_order_items_summary
AFTER INSERT OR UPDATE OR DELETE ON order_items
FOR EACH ROW EXECUTE FUNCTION update_order_summary();

Anti-Patterns

  • No primary key: Every table must have a primary key. Without one, you cannot reliably update or delete specific rows.
  • Using VARCHAR for everything: Use appropriate types -- INT for counts, DECIMAL for money, TIMESTAMPTZ for timestamps, BOOLEAN for flags.
  • Missing foreign keys: Skipping foreign keys "for performance" leads to orphaned records and data corruption. The database should enforce referential integrity.
  • Over-indexing: Every index slows writes and uses storage. Only index columns that appear in frequent query WHERE, JOIN, or ORDER BY clauses.
  • Premature denormalization: Start normalized. Denormalize only when query performance is measurably insufficient and indexing cannot help.
  • Using soft deletes everywhere:
    deleted_at
    columns add complexity to every query. Use them only when audit history is a real requirement.

Quick Reference

Normal FormRuleFix
1NFNo repeating groupsOne value per cell, separate table for lists
2NFNo partial dependenciesEvery non-key column depends on the full PK
3NFNo transitive dependenciesNon-key columns depend only on the PK
Index TypeUse Case
B-tree (default)Equality, range, ORDER BY
HashEquality only (rare)
GINFull-text, JSONB, arrays
GiSTGeometric, range types
PartialQueries filtering on a specific value