Design schemas that survive production
Normalise first. Index for query patterns. Never sacrifice data integrity for convenience.
Design process
Step 1 — Requirements → Entities
Extract nouns from the requirement. Each noun that has attributes and participates in relationships is likely an entity. Resist making everything a single wide table.
Step 2 — Relationships
Identify cardinality before choosing the table structure:
| Relationship |
Implementation |
| 1:1 |
Foreign key on the less-common side (or same table if always loaded together) |
| 1:N |
Foreign key on the N side |
| M:N |
Junction table with FK to both sides; add attributes to the junction if needed |
Step 3 — Normalisation
Target 3NF for transactional data:
- 1NF — atomic values; no repeating groups; primary key identifies each row.
- 2NF — no partial dependencies (non-key column depends on the whole PK, not a subset).
- 3NF — no transitive dependencies (non-key column depends only on the PK, not on another non-key column).
Denormalise deliberately for read-heavy analytics tables — document the trade-off.
Standard conventions
- Primary key:
id (UUID v7 for distributed systems; BIGSERIAL for single-node).
- Timestamps:
created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now().
- Soft delete:
deleted_at TIMESTAMPTZ nullable — add a partial index WHERE deleted_at IS NULL.
- Audit trail: separate
audit_log table with entity_type, entity_id, action, actor_id, changed_at, before, after.
- Multi-tenancy:
tenant_id on every tenant-scoped table; composite primary key or FK constraint; RLS policy.
Index strategy
- Index every foreign key (databases do not do this automatically).
- Add composite indexes for the most common
WHERE col1 = ? AND col2 = ? patterns.
- Partial indexes for filtered queries (
WHERE deleted_at IS NULL, WHERE status = 'active').
- Covering indexes (
INCLUDE (col)) to avoid heap lookups on hot read paths.
- Drop indexes that are never used — they slow writes.
Migration safety
| Change |
Safe? |
Notes |
| Add nullable column |
Yes |
|
| Add NOT NULL with default |
Risky on large tables |
Add nullable, backfill, add constraint |
| Rename column |
No |
Add new, backfill, drop old — across deploys |
| Drop column |
No |
Mark unused, deploy, then drop |
| Add index |
Yes (CONCURRENT) |
CREATE INDEX CONCURRENTLY — never blocking |
| Change column type |
No |
New column + backfill pattern |
Steps
- Read the lore + existing schema.
search_lore for ORM conventions, migration tool (Drizzle/Prisma/Alembic), naming rules, and existing patterns. Extend; don't contradict.
- Extract entities and relationships from the requirements. Draw the ERD (Mermaid is fine) before writing DDL.
- Normalise to 3NF. Document any deliberate denormalisation.
- Write migrations. Use the repo's migration tool. Each migration: one logical change, reversible (
up/down), idempotent.
- Add indexes. At minimum: every FK; composite for the top query patterns identified from the ticket.
- Generate types. Derive TypeScript interfaces or Python Pydantic models from the schema — do not hand-write them separately.
- Verify. Run migrations against a real database; confirm
EXPLAIN ANALYZE on the primary query patterns shows index scans, not sequential scans. Record evidence.
Review checklist
- 3NF achieved — no partial or transitive dependencies, or denormalisation is documented.
- Every FK indexed — check
pg_indexes or equivalent.
- Migrations reversible —
down migration exists and tested.
- No blocking DDL — index creation uses
CONCURRENTLY; large table alterations use the add-backfill-constraint pattern.
- Timestamps on every table —
created_at, updated_at.
- Types generated — not hand-written from the schema.
Rules
- Never rename a column in a single migration on a live table — three-deploy pattern only.
CREATE INDEX CONCURRENTLY always — blocking index creation on production tables is a SEV2.
- Audit trail for every table that stores user-facing data with compliance implications.
Capture lore
ORM choice, migration tool, naming conventions, UUID strategy, and RLS policy are high-value schema lore — call suggest_lore with tags: [database, schema, migrations].
1---2name: database-schema-designer3description: Use when designing new database tables from requirements, reviewing a schema for normalisation or performance issues, adding multi-tenancy, planning a breaking migration, or generating TypeScript/Python types from a schema. Triggers on "design the schema", "ERD", "table relationships", "schema migration", "normalise this", or "database model".4---56# Design schemas that survive production78Normalise first. Index for query patterns. Never sacrifice data integrity for convenience.910## Design process1112### Step 1 — Requirements → Entities1314Extract nouns from the requirement. Each noun that has attributes and participates in relationships is likely an entity. Resist making everything a single wide table.1516### Step 2 — Relationships1718Identify cardinality before choosing the table structure:1920| Relationship | Implementation |21|-------------|---------------|22| 1:1 | Foreign key on the less-common side (or same table if always loaded together) |23| 1:N | Foreign key on the N side |24| M:N | Junction table with FK to both sides; add attributes to the junction if needed |2526### Step 3 — Normalisation2728Target 3NF for transactional data:29301. **1NF** — atomic values; no repeating groups; primary key identifies each row.312. **2NF** — no partial dependencies (non-key column depends on the whole PK, not a subset).323. **3NF** — no transitive dependencies (non-key column depends only on the PK, not on another non-key column).3334Denormalise deliberately for read-heavy analytics tables — document the trade-off.3536## Standard conventions3738- Primary key: `id` (UUID v7 for distributed systems; BIGSERIAL for single-node).39- Timestamps: `created_at TIMESTAMPTZ DEFAULT now()`, `updated_at TIMESTAMPTZ DEFAULT now()`.40- Soft delete: `deleted_at TIMESTAMPTZ` nullable — add a partial index `WHERE deleted_at IS NULL`.41- Audit trail: separate `audit_log` table with `entity_type`, `entity_id`, `action`, `actor_id`, `changed_at`, `before`, `after`.42- Multi-tenancy: `tenant_id` on every tenant-scoped table; composite primary key or FK constraint; RLS policy.4344## Index strategy4546- Index every foreign key (databases do not do this automatically).47- Add composite indexes for the most common `WHERE col1 = ? AND col2 = ?` patterns.48- Partial indexes for filtered queries (`WHERE deleted_at IS NULL`, `WHERE status = 'active'`).49- Covering indexes (`INCLUDE (col)`) to avoid heap lookups on hot read paths.50- Drop indexes that are never used — they slow writes.5152## Migration safety5354| Change | Safe? | Notes |55|--------|-------|-------|56| Add nullable column | Yes | |57| Add NOT NULL with default | Risky on large tables | Add nullable, backfill, add constraint |58| Rename column | No | Add new, backfill, drop old — across deploys |59| Drop column | No | Mark unused, deploy, then drop |60| Add index | Yes (CONCURRENT) | `CREATE INDEX CONCURRENTLY` — never blocking |61| Change column type | No | New column + backfill pattern |6263## Steps64651. **Read the lore + existing schema.** `search_lore` for ORM conventions, migration tool (Drizzle/Prisma/Alembic), naming rules, and existing patterns. Extend; don't contradict.662. **Extract entities and relationships** from the requirements. Draw the ERD (Mermaid is fine) before writing DDL.673. **Normalise to 3NF.** Document any deliberate denormalisation.684. **Write migrations.** Use the repo's migration tool. Each migration: one logical change, reversible (`up`/`down`), idempotent.695. **Add indexes.** At minimum: every FK; composite for the top query patterns identified from the ticket.706. **Generate types.** Derive TypeScript interfaces or Python Pydantic models from the schema — do not hand-write them separately.717. **Verify.** Run migrations against a real database; confirm `EXPLAIN ANALYZE` on the primary query patterns shows index scans, not sequential scans. Record evidence.7273## Review checklist7475- **3NF achieved** — no partial or transitive dependencies, or denormalisation is documented.76- **Every FK indexed** — check `pg_indexes` or equivalent.77- **Migrations reversible** — `down` migration exists and tested.78- **No blocking DDL** — index creation uses `CONCURRENTLY`; large table alterations use the add-backfill-constraint pattern.79- **Timestamps on every table** — `created_at`, `updated_at`.80- **Types generated** — not hand-written from the schema.8182## Rules8384- Never rename a column in a single migration on a live table — three-deploy pattern only.85- `CREATE INDEX CONCURRENTLY` always — blocking index creation on production tables is a SEV2.86- Audit trail for every table that stores user-facing data with compliance implications.8788## Capture lore8990ORM choice, migration tool, naming conventions, UUID strategy, and RLS policy are high-value schema lore — call `suggest_lore` with `tags: [database, schema, migrations]`.