kid-sid/claude-spellbook/database-design

Claude Code and agent skill by kid-sid/claude-spellbook

Trust tier 3
  • community
Source check pending

Checking that the SKILL.md file is still in the repository.

Use when designing a relational schema — normalization (1NF–3NF), primary key strategy, modelling relationships, naming conventions, or planning a zero-downtime migration with the expand-contract pattern. For writing and tuning queries, indexes, or EXPLAIN ANALYZE, use postgresql.

Install

npx vskill@latest install kid-sid/claude-spellbook/database-design

SKILL.md

Database Design

A practical reference for designing, indexing, migrating, and operating relational databases in production, with PostgreSQL as the primary target.

When to Activate

  • Designing a new database schema or data model
  • Adding indexes to improve query performance
  • Writing or reviewing a database migration
  • Optimizing a slow SQL query
  • Choosing between normalization and denormalization strategies
  • Setting up connection pooling for a service
  • Planning a zero-downtime schema change in production

Schema Design Principles

Normalization

First Normal Form (1NF) — atomic values, no repeating groups.

sql
-- BAD: repeating groups in a single column
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
item_ids TEXT NOT NULL -- "1,2,3" stored as a string
);
-- GOOD: each value gets its own row
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id)
);

Second Normal Form (2NF) — no partial dependencies on a composite key. Every non-key column must depend on the whole key, not just part of it.

sql
-- BAD: composite key (order_id, product_id), but product_name depends only on product_id
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_name TEXT NOT NULL, -- partial dependency: only on product_id
quantity INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);
-- GOOD: move product_name to the products table
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INT NOT NULL,
PRIMARY KEY (order_id, product_id)
);

Third Normal Form (3NF) — no transitive dependencies. Non-key columns must not depend on other non-key columns.

sql
-- BAD: zip_code -> city is a transitive dependency; city is not a key
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
zip_code VARCHAR(10),
city TEXT -- city depends on zip_code, not on id
);
-- GOOD: extract the transitive dependency into its own table
CREATE TABLE zip_codes (
zip_code VARCHAR(10) PRIMARY KEY,
city TEXT NOT NULL,
state CHAR(2) NOT NULL
);
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
zip_code VARCHAR(10) REFERENCES zip_codes(zip_code)
);

Deliberate denormalization — acceptable in specific scenarios; always document the reason.

ScenarioDenormalization PatternDocument as
Reporting / analytics tablesPre-aggregated summary columns-- DENORM: avoids JOIN on reporting queries
Heavy read workloadDuplicated display name on child table-- DENORM: read:write ratio > 100:1
Caching aggregatestotal_order_count on users table-- DENORM: updated by trigger, cache of COUNT(orders)

Primary Key Strategies
StrategyTypeProsConsBest For
BIGSERIAL / auto-increment8-byte integerSimple, small, ordered, fast joinsLeaks row count; bad for distributed insertsSingle-database apps, internal IDs
UUID v416-byte randomGlobally unique, no orderingIndex fragmentation, 16 bytes, not sortableDistributed systems (legacy)
UUID v716-byte time-orderedGlobally unique, time-sortable, B-tree friendlySlightly larger than integerNew distributed systems (preferred)
Natural keyVariesHuman-readable, no surrogate neededHard to change; rarely truly immutableISO country codes, currency codes

Use BIGSERIAL for most tables. Prefer UUID v7 over UUID v4 for distributed or externally-exposed IDs.

sql
-- BIGSERIAL
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY
);
-- UUID v7 (requires pg_uuidv7 extension or application-generated)
CREATE EXTENSION IF NOT EXISTS "pg_uuidv7";
CREATE TABLE events (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7()
);

Data Type Selection
Use CasePreferred TypeAvoidReason
Short textVARCHAR(n) or TEXTCHAR(n)CHAR pads with spaces, wastes storage
TimestampsTIMESTAMPTZTIMESTAMPTIMESTAMP has no timezone — silent bugs in multi-region apps
Money / currencyNUMERIC(10,2)FLOAT / DOUBLE PRECISIONFloating point errors accumulate in financial calculations
Semi-structured dataJSONBJSONJSONB is binary, indexed, and faster to query
BooleanBOOLEANINT (0/1)Semantic clarity; BOOLEAN accepts TRUE/FALSE/NULL
EnumsLookup table or TEXT CHECKPostgreSQL ENUM typeENUM requires ALTER TYPE to add values; lookup tables are easier to extend
sql
-- BAD: float for money, timestamp without timezone, int for boolean
CREATE TABLE invoices (
id SERIAL PRIMARY KEY,
amount FLOAT, -- BAD: floating point
created_at TIMESTAMP, -- BAD: no timezone
is_paid INT DEFAULT 0 -- BAD: use BOOLEAN
);
-- GOOD
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
amount NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
is_paid BOOLEAN NOT NULL DEFAULT FALSE
);

Naming Conventions

  • Tables: snake_case, plural — users, order_items, audit_logs
  • Columns: snake_case, singular — user_id, created_at, is_active
  • Foreign keys: <referenced_table_singular>_id — user_id references users(id)
  • Indexes: idx_<table>_<columns> — idx_users_email, idx_orders_user_id_created_at
  • Unique constraints: uq_<table>_<col> — uq_users_email
  • Foreign key constraints: fk_<table>_<referenced> — fk_orders_users
  • Check constraints: ck_<table>_<rule> — ck_products_price_positive
sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
total NUMERIC(12,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_orders_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT ck_orders_total_pos CHECK (total >= 0)
);
CREATE UNIQUE INDEX uq_users_email ON users (email);
CREATE INDEX idx_orders_user_id ON orders (user_id);

Relationships and Foreign Keys

One-to-Many

The "many" side holds the foreign key column.

sql
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
title TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_posts_users FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE CASCADE
);

Cascade options:

ON DELETE BehaviorWhen to Use
CASCADEChild rows are meaningless without the parent (e.g., post comments deleted when post is deleted)
SET NULLChild row survives but loses its association (e.g., assigned user deleted, task becomes unassigned)
RESTRICTPrevent deletion of parent if children exist — use when you want explicit cleanup
NO ACTIONSame as RESTRICT but deferred; default if unspecified — almost always specify explicitly

Many-to-Many

Use a junction table with a composite primary key. Always include created_at.

sql
CREATE TABLE user_roles (
user_id BIGINT NOT NULL,
role_id BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, role_id),
CONSTRAINT fk_user_roles_users FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_user_roles_roles FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

Adding created_at costs nothing and you will always want it when auditing membership changes.


Polymorphic Associations

Anti-pattern — using entity_type + entity_id with a single generic foreign key column breaks referential integrity because PostgreSQL cannot enforce a foreign key across multiple tables.

sql
-- BAD: no FK enforcement, fragile JOIN logic
CREATE TABLE comments (
id BIGSERIAL PRIMARY KEY,
entity_type TEXT NOT NULL, -- 'post' | 'photo' | 'video'
entity_id BIGINT NOT NULL, -- could reference any table
body TEXT NOT NULL
);
-- No FK here — the database has no idea what entity_id points to.

Preferred pattern — separate join tables per entity type, each with a proper foreign key.

sql
-- GOOD: separate relationship tables, full FK enforcement
CREATE TABLE post_comments (
id BIGSERIAL PRIMARY KEY,
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE photo_comments (
id BIGSERIAL PRIMARY KEY,
photo_id BIGINT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

Indexing Strategy

Index Types
TypeWhen to UsePostgreSQL Syntax
B-treeEquality and range queries; default for most columnsCREATE INDEX idx_name ON tbl (col)
HashEquality-only predicates; slightly faster than B-tree for equalityCREATE INDEX idx_name ON tbl USING HASH (col)
GINJSONB fields, arrays, full-text search (tsvector)CREATE INDEX idx_name ON tbl USING GIN (col)
GiSTGeometric data, PostGIS, range types (tsrange, int4range)CREATE INDEX idx_name ON tbl USING GIST (col)
PartialIndex a filtered subset of rowsCREATE INDEX idx_name ON tbl (col) WHERE condition
sql
-- B-tree (default): range query on created_at
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);
-- GIN: query inside JSONB column
CREATE INDEX CONCURRENTLY idx_events_payload ON events USING GIN (payload);
-- Partial: only index active (non-deleted) rows
CREATE INDEX CONCURRENTLY idx_users_email_active ON users (email)
WHERE deleted_at IS NULL;
-- Full-text search
CREATE INDEX CONCURRENTLY idx_articles_fts ON articles USING GIN (to_tsvector('english', title || ' ' || body));

Composite Index Column Order

Rule: put equality predicate columns first, range predicate columns last.

sql
-- Query: WHERE tenant_id = $1 AND created_at > $2 ORDER BY created_at DESC
-- BAD: range column first — tenant_id predicate cannot use the index efficiently
CREATE INDEX idx_orders_bad ON orders (created_at, tenant_id);
-- GOOD: equality column first, then range column
CREATE INDEX idx_orders_tenant_created ON orders (tenant_id, created_at);

Covering index — include non-key columns with INCLUDE to avoid a heap fetch (index-only scan).

sql
-- Without INCLUDE: index scan + heap fetch for each row to retrieve status
CREATE INDEX idx_orders_user ON orders (user_id, created_at);
-- With INCLUDE: index-only scan if status is the only extra column needed
CREATE INDEX idx_orders_user_covering ON orders (user_id, created_at)
INCLUDE (status, total);

Index Bloat
sql
-- Check index bloat with pgstattuple (requires extension)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT * FROM pgstatindex('idx_orders_user_id');
-- free_percent > 30 is a sign of bloat worth addressing
-- Rebuild index without locking (safe for production)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

This is the start of the file. Read the full SKILL.md on GitHub.

Security

  • Security scanPassed, 100 of 100Pattern checks on the skill's files.
  • Model reviewPassed

Quality evaluation

  • Task evaluationNot runNo evaluation yet. Run one in Skill Studio.

Source

Popularity

Repository stars
186
Repository forks
15
7-day trend score
26

Stars and forks are for the whole GitHub repository, not this skill alone. Not refreshed yet.

Works with

Any agent vskill installs to: 56 targets in all. These 39 have a page on this site.

  • Claude Code
  • Cursor
  • GitHub Copilot
  • Windsurf
  • Codex
  • Gemini CLI
  • Cline
  • Amp
  • Roo Code
  • Goose
  • Continue
  • Kilo Code
  • Junie
  • Trae
  • OpenHands
  • Qwen Code
  • AI Agent
  • Tabnine Chat
  • Sourcegraph Cody
  • Amazon Q
  • Firebase Genkit
  • Sweep
  • Devin
  • Phind
  • Mentat
  • Claude Desktop
  • Aider
  • GPT Pilot
  • Plandex
  • bolt.new
  • v0
  • Replit Agent
  • Anse
  • TypingMind
  • BetterChatGPT
  • LibreChat
  • Warp AI
  • Pieces
  • Fig AI
Embed badge
vskill VERIFIED badge
[![vskill VERIFIED](https://verified-skill.com/api/v1/skills/kid-sid/claude-spellbook/database-design/badge)](https://verified-skill.com/skills/kid-sid/claude-spellbook/database-design)

Something wrong with this skill or its page? Tell us.