Database Schema Design Skill

Document or design a database schema with entity relationships, table definitions, constraints, indexes, and access patterns.

Database Schema Design Skill — The Skill Playground: pick the Executive Update skill, fill in a few notes, hit run, and watch a structured executive… (from the mohitagw15856/pm-claude-skills README)

From the mohitagw15856/pm-claude-skills README — shows the whole collection, not only this skill. · view on GitHub

How to use it

Claude Code
  1. Run the line below. It pulls the whole folder into ~/.claude/skills/database-schema-design.
  2. Describe your job in plain words. Claude Code follows the skill from there.
Claude Code — installs the whole folder, not just SKILL.md
npx degit mohitagw15856/pm-claude-skills/skills/database-schema-design#main ~/.claude/skills/database-schema-design

For one project only, change the path to .claude/skills/database-schema-design.

Claude (web or desktop app)
  1. On this page open ⋯ → Download .md.
  2. Save it as SKILL.md in a folder, zip the folder, then Customize → Skills → + → Create skill → Upload a skill.
  3. Pick the file and Save. Claude shows the name and description and runs a security scan.
  4. Check the skill is switched on.
  5. Start a new chat and describe your job in plain words. The AI follows the skill from there.
ChatGPT or another app
  1. ChatGPT: make a Project and paste it into Instructions.
  2. Neither? Paste it at the top of a new chat — it works for that chat.
Not working?
  • Check which app you pasted it into — the steps above name the right one.
  • Some skills need the paid tier of Claude or ChatGPT.
Step-by-step guide with screenshots · Ask in the forum

Paste into Claude, ChatGPT or Cursor.

Source of Database Schema Design Skill

Show the full text365 lines
namedescription
database-schema-designDocument or design a database schema with entity relationships, table definitions, constraints, indexes, and access patterns. Use when asked to design a database, document an existing schema, model entities and relationships, define table structures, plan an index strategy, or produce a data model for review. Produces a structured schema document covering an ER diagram, table DDL definitions, index strategy, access pattern analysis, normalization decisions, and migration notes.

Database Schema Design Skill

Produce a complete database schema design document for a given domain. A schema document is not just a list of tables — it is a record of decisions: what was modelled, how entities relate, which queries the schema is optimised for, and what trade-offs were made.

A good schema design document lets an engineer understand the data model, query it correctly, extend it safely, and write migrations without breaking things.

Required Inputs

Ask for these if not already provided:

  • Domain description — what the system does; what business objects are being modelled
  • Entities and relationships — the main things in the domain and how they relate (e.g. "a User has many Orders; an Order has many OrderItems; an OrderItem references a Product")
  • Expected query patterns — the most important read and write queries (e.g. "fetch all orders for a user, sorted by date"; "look up a product by SKU")
  • Database engine — PostgreSQL, MySQL, SQLite, CockroachDB, etc. — this affects DDL syntax and available types
  • Expected data volume — approximate row counts, growth rate, and any partitioning needs
  • Constraints — any existing conventions, naming standards, or migration constraints to respect

Output Format


Database Schema Design: [Domain / Service Name]

Service: [Name] | Team: [Team name] Author: [Name] | Reviewed by: [Name] Date: [Date] | Database engine: [PostgreSQL X.X / MySQL X.X / etc.] Status: [Draft / Reviewed / Approved]


1. Overview

[2–3 sentences describing the domain being modelled, the scope of this schema, and any key design philosophy (e.g. "this schema prioritises read performance for the customer-facing API over write simplicity", or "designed for eventual migration to multi-tenancy")]

In scope:

  • [Entity or subsystem]
  • [Entity or subsystem]

Out of scope:

  • [e.g. Analytics / reporting tables — separate schema]
  • [e.g. Audit log tables — covered in separate design doc]

2. Entity Relationship Diagram

┌───────────────────┐         ┌───────────────────────┐
│      users        │         │       organisations    │
│─────────────────  │         │─────────────────────── │
│ id (PK)           │    ┌───▶│ id (PK)                │
│ org_id (FK)  ─────┼────┘    │ name                   │
│ email             │         │ plan                   │
│ display_name      │         │ created_at             │
│ created_at        │         └───────────────────────┘
│ updated_at        │
└─────────┬─────────┘
          │ 1
          │
          │ N
┌─────────▼─────────┐         ┌───────────────────────┐
│      [table_a]    │         │      [table_b]         │
│─────────────────  │         │─────────────────────── │
│ id (PK)           │    N    │ id (PK)                │
│ user_id (FK) ─────┼────────▶│ [table_a]_id (FK)      │
│ [field]           │    │    │ [field]                │
│ [field]           │    │    │ [field]                │
│ created_at        │         │ created_at             │
└───────────────────┘         └───────────────────────┘

Relationship summary:

Entity A Relationship Entity B Notes
organisations has many users An org can have many users
users has many [table_a] Soft-deleted on user deletion
[table_a] has many [table_b] Cascade delete
[table_b] belongs to [table_a] Non-nullable FK
[table_c] many-to-many (via [join_table]) [table_d] Join table with metadata

3. Table Definitions

organisations

[1 sentence describing what this table stores and its role in the domain.]

CREATE TABLE organisations (
    id          UUID            PRIMARY KEY DEFAULT gen_random_uuid(),
    name        VARCHAR(255)    NOT NULL,
    slug        VARCHAR(100)    NOT NULL UNIQUE,
    plan        VARCHAR(50)     NOT NULL DEFAULT 'free'
                                CHECK (plan IN ('free', 'pro', 'enterprise')),
    settings    JSONB           NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);
Column Type Nullable Default Notes
id UUID No gen_random_uuid() Surrogate PK — UUID preferred over serial for distributed use
name VARCHAR(255) No — Display name; not unique
slug VARCHAR(100) No — URL-safe identifier; unique across all orgs
plan VARCHAR(50) No 'free' Constrained to known values via CHECK
settings JSONB No {} Flexible config; avoid for queryable fields
created_at TIMESTAMPTZ No now() Always use TIMESTAMPTZ, not TIMESTAMP
updated_at TIMESTAMPTZ No now() Updated via trigger (see below)

users

[1 sentence describing what this table stores.]

CREATE TABLE users (
    id              UUID            PRIMARY KEY DEFAULT gen_random_uuid(),
    org_id          UUID            NOT NULL REFERENCES organisations(id)
                                    ON DELETE RESTRICT,
    email           VARCHAR(254)    NOT NULL,
    display_name    VARCHAR(255)    NOT NULL DEFAULT '',
    role            VARCHAR(50)     NOT NULL DEFAULT 'member'
                                    CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
    email_verified  BOOLEAN         NOT NULL DEFAULT false,
    deleted_at      TIMESTAMPTZ     NULL,
    created_at      TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at      TIMESTAMPTZ     NOT NULL DEFAULT now(),

    CONSTRAINT users_email_org_unique UNIQUE (email, org_id)
);
Column Type Nullable Default Notes
id UUID No gen_random_uuid() —
org_id UUID No — FK to organisations; RESTRICT prevents orphaning
email VARCHAR(254) No — RFC 5321 max length; unique per org (not globally)
role VARCHAR(50) No 'member' Application-level RBAC
deleted_at TIMESTAMPTZ Yes NULL Soft delete; NULL = active

Soft delete policy: Rows with deleted_at IS NOT NULL are considered deleted. All application queries MUST filter WHERE deleted_at IS NULL unless explicitly fetching deleted records. Use a view or ORM scope to enforce this.


[table_a]

[Description of what this table models.]

CREATE TABLE [table_a] (
    id          UUID            PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID            NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    [field_1]   VARCHAR(255)    NOT NULL,
    [field_2]   TEXT            NULL,
    [field_3]   INTEGER         NOT NULL DEFAULT 0 CHECK ([field_3] >= 0),
    status      VARCHAR(50)     NOT NULL DEFAULT 'pending'
                                CHECK (status IN ('pending', 'active', 'archived')),
    metadata    JSONB           NOT NULL DEFAULT '{}',
    created_at  TIMESTAMPTZ     NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ     NOT NULL DEFAULT now()
);
Column Type Nullable Notes
user_id UUID No CASCADE delete — when user is deleted, their [table_a] rows are too
[field_1] VARCHAR(255) No [Reason for length constraint]
status VARCHAR(50) No State machine: pending → active → archived (no other transitions)
metadata JSONB No [What is stored here and why it's not a typed column]

[join_table] (Many-to-many)

[Description of the relationship this table represents.]

CREATE TABLE [join_table] (
    [table_c]_id    UUID        NOT NULL REFERENCES [table_c](id) ON DELETE CASCADE,
    [table_d]_id    UUID        NOT NULL REFERENCES [table_d](id) ON DELETE CASCADE,
    granted_by      UUID        NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
    granted_at      TIMESTAMPTZ NOT NULL DEFAULT now(),

    PRIMARY KEY ([table_c]_id, [table_d]_id)
);

Why a composite PK: The combination of [table_c]_id + [table_d]_id is the natural key — each association is unique and the primary key doubles as the uniqueness constraint without needing a separate index.


4. Index Strategy

For each table, define which indexes are created and why. Include the query they are designed to serve.

Table Index name Columns Type Query served Notes
users users_org_id_idx (org_id) B-tree SELECT * FROM users WHERE org_id = $1 FK lookup; required for join performance
users users_email_lower_idx (lower(email)) B-tree (functional) WHERE lower(email) = lower($1) Case-insensitive email lookup
users users_active_by_org_idx (org_id, created_at DESC) B-tree WHERE org_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC Partial index candidate (see below)
[table_a] [table_a]_user_id_status_idx (user_id, status) B-tree WHERE user_id = $1 AND status = 'active' Compound — order matters
[table_a] [table_a]_metadata_gin_idx metadata GIN WHERE metadata @> '{"key": "value"}' Only add if JSONB queried frequently

Partial indexes (PostgreSQL):

-- Index only active (non-deleted) users — dramatically smaller for soft-delete tables
CREATE INDEX users_active_email_idx
    ON users (email, org_id)
    WHERE deleted_at IS NULL;

-- Index only pending items — avoids indexing the majority of rows
CREATE INDEX [table_a]_pending_idx
    ON [table_a] (user_id, created_at)
    WHERE status = 'pending';

Index design principles applied:

  • FKs that appear in JOIN conditions always have an index
  • Compound indexes follow selectivity order: most selective column first
  • Functional indexes for case-insensitive lookups
  • GIN indexes only where JSONB containment queries are frequent
  • Partial indexes for status-filtered queries on large tables

5. Access Pattern Analysis

Document the primary queries this schema is designed to serve. For each, show the query, the indexes used, and any caveats.

AP-1: Fetch all active users for an organisation (paginated)

Frequency: Very high — called on every dashboard load Query:

SELECT id, email, display_name, role, created_at
FROM users
WHERE org_id = $1
  AND deleted_at IS NULL
ORDER BY created_at DESC
LIMIT 50 OFFSET $2;

Index used: users_active_by_org_idx (org_id, created_at DESC) Notes: Use keyset pagination (WHERE created_at < $cursor) at scale; OFFSET degrades past ~10k rows.


AP-2: Look up a user by email (case-insensitive)

Frequency: High — every authentication attempt Query:

SELECT id, org_id, role, email_verified
FROM users
WHERE lower(email) = lower($1)
  AND deleted_at IS NULL;

Index used: users_email_lower_idx Notes: Returns multiple rows if same email exists across orgs. Application resolves by org context.


AP-3: Fetch [table_a] items for a user by status

Frequency: High Query:

SELECT *
FROM [table_a]
WHERE user_id = $1
  AND status = $2
ORDER BY created_at DESC
LIMIT 25;

Index used: [table_a]_user_id_status_idx Notes: Compound index covers both filter columns. Status filter must come second in the index because user_id is more selective.


AP-4: [Add further access patterns as needed]

6. Normalization Decisions

Document deliberate choices to normalize or denormalize, with reasoning.

Decision Approach Reasoning
[e.g. Organisation name on users table?] Not denormalized — always join to organisations Avoid stale copies; org name changes are infrequent and joining is cheap
[e.g. Status history] Not in this table — separate [table_a]_status_history if needed Current status is all that's needed for 99% of queries; history is auditing, not application data
[e.g. JSONB settings column on organisations] Denormalized into JSONB Settings are read together; never queried by field; schema changes don't require migrations
[e.g. Computed aggregate counts] Not stored — computed at query time Counts are small; maintaining a counter column requires careful locking; use SELECT COUNT(*) with the index

7. Triggers and Automation

-- Automatically update updated_at on any row modification
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = now();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply to all tables with updated_at
CREATE TRIGGER users_updated_at
    BEFORE UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();

CREATE TRIGGER [table_a]_updated_at
    BEFORE UPDATE ON [table_a]
    FOR EACH ROW EXECUTE FUNCTION set_updated_at();

8. Migration Notes

If this schema is being introduced to an existing system, note the migration approach.

Step Description Backward compatible Risk
1 Create organisations table Yes — additive Low
2 Create users table Yes — additive Low
3 Backfill org_id on existing users Requires dual-write period Medium
4 Add NOT NULL constraint on org_id Requires backfill to be 100% complete Medium
5 Remove deprecated columns Requires app code updated first Low once app deployed

Backfill strategy: [Describe how to handle existing data — batch size, rate limiting, validation queries]

Rollback: Each migration step should be independently reversible. See [database-migration-plan skill] for the full rollback procedure template.


Quality Checks

  • Every table has a primary key and a created_at column — no implicit ordering by row insertion
  • Every foreign key has a corresponding index — no missing FK indexes that would cause full table scans on joins
  • All TIMESTAMPTZ columns, not TIMESTAMP — timezone awareness is explicit
  • Soft-delete tables document the convention and where the filter is enforced (ORM scope, view, or query standard)
  • Every access pattern in the design has a supporting index or an explicit note that a full table scan is acceptable
  • JSONB columns are justified — not used as a substitute for proper schema design on queryable fields
  • Normalization decisions are documented with reasoning, not just stated
  • Migration notes address existing data if this is a schema change, not a greenfield schema

Anti-Patterns

  • Do not use JSONB columns as a substitute for proper relational schema design on fields that will be queried
  • Do not add indexes speculatively — every index must be justified by a specific access pattern
  • Do not omit timezone-awareness — use TIMESTAMPTZ, never plain TIMESTAMP
  • Do not design without documenting normalization decisions — future maintainers need the reasoning, not just the structure
  • Do not skip the access patterns section — schema without query patterns cannot be evaluated for correctness
1---
2name: database-schema-design
3description: "Document or design a database schema with entity relationships, table definitions, constraints, indexes, and access patterns. Use when asked to design a database, document an existing schema, model entities and relationships, define table structures, plan an index strategy, or produce a data model for review. Produces a structured schema document covering an ER diagram, table DDL definitions, index strategy, access pattern analysis, normalization decisions, and migration notes."
4---
5 
6# Database Schema Design Skill
7 
8Produce a complete database schema design document for a given domain. A schema document is not just a list of tables — it is a record of decisions: what was modelled, how entities relate, which queries the schema is optimised for, and what trade-offs were made.
9 
10A good schema design document lets an engineer understand the data model, query it correctly, extend it safely, and write migrations without breaking things.
11 
12## Required Inputs
13 
14Ask for these if not already provided:
15- **Domain description** — what the system does; what business objects are being modelled
16- **Entities and relationships** — the main things in the domain and how they relate (e.g. "a User has many Orders; an Order has many OrderItems; an OrderItem references a Product")
17- **Expected query patterns** — the most important read and write queries (e.g. "fetch all orders for a user, sorted by date"; "look up a product by SKU")
18- **Database engine** — PostgreSQL, MySQL, SQLite, CockroachDB, etc. — this affects DDL syntax and available types
19- **Expected data volume** — approximate row counts, growth rate, and any partitioning needs
20- **Constraints** — any existing conventions, naming standards, or migration constraints to respect
21 
22## Output Format
23 
24---
25 
26# Database Schema Design: [Domain / Service Name]
27 
28**Service:** [Name] | **Team:** [Team name]
29**Author:** [Name] | **Reviewed by:** [Name]
30**Date:** [Date] | **Database engine:** [PostgreSQL X.X / MySQL X.X / etc.]
31**Status:** [Draft / Reviewed / Approved]
32 
33---
34 
35## 1. Overview
36 
37[2–3 sentences describing the domain being modelled, the scope of this schema, and any key design philosophy (e.g. "this schema prioritises read performance for the customer-facing API over write simplicity", or "designed for eventual migration to multi-tenancy")]
38 
39**In scope:**
40- [Entity or subsystem]
41- [Entity or subsystem]
42 
43**Out of scope:**
44- [e.g. Analytics / reporting tables — separate schema]
45- [e.g. Audit log tables — covered in separate design doc]
46 
47---
48 
49## 2. Entity Relationship Diagram
50 
51```
52┌───────────────────┐ ┌───────────────────────┐
53│ users │ │ organisations │
54│───────────────── │ │─────────────────────── │
55│ id (PK) │ ┌───▶│ id (PK) │
56│ org_id (FK) ─────┼────┘ │ name │
57│ email │ │ plan │
58│ display_name │ │ created_at │
59│ created_at │ └───────────────────────┘
60│ updated_at │
61└─────────┬─────────┘
62 │ 1
63 │
64 │ N
65┌─────────▼─────────┐ ┌───────────────────────┐
66│ [table_a] │ │ [table_b] │
67│───────────────── │ │─────────────────────── │
68│ id (PK) │ N │ id (PK) │
69│ user_id (FK) ─────┼────────▶│ [table_a]_id (FK) │
70│ [field] │ │ │ [field] │
71│ [field] │ │ │ [field] │
72│ created_at │ │ created_at │
73└───────────────────┘ └───────────────────────┘
74```
75 
76**Relationship summary:**
77 
78| Entity A | Relationship | Entity B | Notes |
79|---|---|---|---|
80| organisations | has many | users | An org can have many users |
81| users | has many | [table_a] | Soft-deleted on user deletion |
82| [table_a] | has many | [table_b] | Cascade delete |
83| [table_b] | belongs to | [table_a] | Non-nullable FK |
84| [table_c] | many-to-many (via [join_table]) | [table_d] | Join table with metadata |
85 
86---
87 
88## 3. Table Definitions
89 
90### `organisations`
91 
92[1 sentence describing what this table stores and its role in the domain.]
93 
94```sql
95CREATE TABLE organisations (
96 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
97 name VARCHAR(255) NOT NULL,
98 slug VARCHAR(100) NOT NULL UNIQUE,
99 plan VARCHAR(50) NOT NULL DEFAULT 'free'
100 CHECK (plan IN ('free', 'pro', 'enterprise')),
101 settings JSONB NOT NULL DEFAULT '{}',
102 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
103 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
104);
105```
106 
107| Column | Type | Nullable | Default | Notes |
108|---|---|---|---|---|
109| id | UUID | No | gen_random_uuid() | Surrogate PK — UUID preferred over serial for distributed use |
110| name | VARCHAR(255) | No | — | Display name; not unique |
111| slug | VARCHAR(100) | No | — | URL-safe identifier; unique across all orgs |
112| plan | VARCHAR(50) | No | 'free' | Constrained to known values via CHECK |
113| settings | JSONB | No | {} | Flexible config; avoid for queryable fields |
114| created_at | TIMESTAMPTZ | No | now() | Always use TIMESTAMPTZ, not TIMESTAMP |
115| updated_at | TIMESTAMPTZ | No | now() | Updated via trigger (see below) |
116 
117---
118 
119### `users`
120 
121[1 sentence describing what this table stores.]
122 
123```sql
124CREATE TABLE users (
125 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
126 org_id UUID NOT NULL REFERENCES organisations(id)
127 ON DELETE RESTRICT,
128 email VARCHAR(254) NOT NULL,
129 display_name VARCHAR(255) NOT NULL DEFAULT '',
130 role VARCHAR(50) NOT NULL DEFAULT 'member'
131 CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
132 email_verified BOOLEAN NOT NULL DEFAULT false,
133 deleted_at TIMESTAMPTZ NULL,
134 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
135 updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
136 
137 CONSTRAINT users_email_org_unique UNIQUE (email, org_id)
138);
139```
140 
141| Column | Type | Nullable | Default | Notes |
142|---|---|---|---|---|
143| id | UUID | No | gen_random_uuid() | — |
144| org_id | UUID | No | — | FK to organisations; RESTRICT prevents orphaning |
145| email | VARCHAR(254) | No | — | RFC 5321 max length; unique per org (not globally) |
146| role | VARCHAR(50) | No | 'member' | Application-level RBAC |
147| deleted_at | TIMESTAMPTZ | Yes | NULL | Soft delete; NULL = active |
148 
149**Soft delete policy:** Rows with `deleted_at IS NOT NULL` are considered deleted. All application queries MUST filter `WHERE deleted_at IS NULL` unless explicitly fetching deleted records. Use a view or ORM scope to enforce this.
150 
151---
152 
153### `[table_a]`
154 
155[Description of what this table models.]
156 
157```sql
158CREATE TABLE [table_a] (
159 id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
160 user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
161 [field_1] VARCHAR(255) NOT NULL,
162 [field_2] TEXT NULL,
163 [field_3] INTEGER NOT NULL DEFAULT 0 CHECK ([field_3] >= 0),
164 status VARCHAR(50) NOT NULL DEFAULT 'pending'
165 CHECK (status IN ('pending', 'active', 'archived')),
166 metadata JSONB NOT NULL DEFAULT '{}',
167 created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
168 updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
169);
170```
171 
172| Column | Type | Nullable | Notes |
173|---|---|---|---|
174| user_id | UUID | No | CASCADE delete — when user is deleted, their [table_a] rows are too |
175| [field_1] | VARCHAR(255) | No | [Reason for length constraint] |
176| status | VARCHAR(50) | No | State machine: pending → active → archived (no other transitions) |
177| metadata | JSONB | No | [What is stored here and why it's not a typed column] |
178 
179---
180 
181### `[join_table]` *(Many-to-many)*
182 
183[Description of the relationship this table represents.]
184 
185```sql
186CREATE TABLE [join_table] (
187 [table_c]_id UUID NOT NULL REFERENCES [table_c](id) ON DELETE CASCADE,
188 [table_d]_id UUID NOT NULL REFERENCES [table_d](id) ON DELETE CASCADE,
189 granted_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
190 granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
191 
192 PRIMARY KEY ([table_c]_id, [table_d]_id)
193);
194```
195 
196**Why a composite PK:** The combination of `[table_c]_id + [table_d]_id` is the natural key — each association is unique and the primary key doubles as the uniqueness constraint without needing a separate index.
197 
198---
199 
200## 4. Index Strategy
201 
202For each table, define which indexes are created and why. Include the query they are designed to serve.
203 
204| Table | Index name | Columns | Type | Query served | Notes |
205|---|---|---|---|---|---|
206| users | `users_org_id_idx` | `(org_id)` | B-tree | `SELECT * FROM users WHERE org_id = $1` | FK lookup; required for join performance |
207| users | `users_email_lower_idx` | `(lower(email))` | B-tree (functional) | `WHERE lower(email) = lower($1)` | Case-insensitive email lookup |
208| users | `users_active_by_org_idx` | `(org_id, created_at DESC)` | B-tree | `WHERE org_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC` | Partial index candidate (see below) |
209| [table_a] | `[table_a]_user_id_status_idx` | `(user_id, status)` | B-tree | `WHERE user_id = $1 AND status = 'active'` | Compound — order matters |
210| [table_a] | `[table_a]_metadata_gin_idx` | `metadata` | GIN | `WHERE metadata @> '{"key": "value"}'` | Only add if JSONB queried frequently |
211 
212**Partial indexes (PostgreSQL):**
213 
214```sql
215-- Index only active (non-deleted) users — dramatically smaller for soft-delete tables
216CREATE INDEX users_active_email_idx
217 ON users (email, org_id)
218 WHERE deleted_at IS NULL;
219 
220-- Index only pending items — avoids indexing the majority of rows
221CREATE INDEX [table_a]_pending_idx
222 ON [table_a] (user_id, created_at)
223 WHERE status = 'pending';
224```
225 
226**Index design principles applied:**
227- FKs that appear in JOIN conditions always have an index
228- Compound indexes follow selectivity order: most selective column first
229- Functional indexes for case-insensitive lookups
230- GIN indexes only where JSONB containment queries are frequent
231- Partial indexes for status-filtered queries on large tables
232 
233---
234 
235## 5. Access Pattern Analysis
236 
237Document the primary queries this schema is designed to serve. For each, show the query, the indexes used, and any caveats.
238 
239### AP-1: Fetch all active users for an organisation (paginated)
240 
241**Frequency:** Very high — called on every dashboard load
242**Query:**
243```sql
244SELECT id, email, display_name, role, created_at
245FROM users
246WHERE org_id = $1
247 AND deleted_at IS NULL
248ORDER BY created_at DESC
249LIMIT 50 OFFSET $2;
250```
251**Index used:** `users_active_by_org_idx` (org_id, created_at DESC)
252**Notes:** Use keyset pagination (`WHERE created_at < $cursor`) at scale; OFFSET degrades past ~10k rows.
253 
254---
255 
256### AP-2: Look up a user by email (case-insensitive)
257 
258**Frequency:** High — every authentication attempt
259**Query:**
260```sql
261SELECT id, org_id, role, email_verified
262FROM users
263WHERE lower(email) = lower($1)
264 AND deleted_at IS NULL;
265```
266**Index used:** `users_email_lower_idx`
267**Notes:** Returns multiple rows if same email exists across orgs. Application resolves by org context.
268 
269---
270 
271### AP-3: Fetch [table_a] items for a user by status
272 
273**Frequency:** High
274**Query:**
275```sql
276SELECT *
277FROM [table_a]
278WHERE user_id = $1
279 AND status = $2
280ORDER BY created_at DESC
281LIMIT 25;
282```
283**Index used:** `[table_a]_user_id_status_idx`
284**Notes:** Compound index covers both filter columns. Status filter must come second in the index because user_id is more selective.
285 
286---
287 
288### AP-4: [Add further access patterns as needed]
289 
290---
291 
292## 6. Normalization Decisions
293 
294Document deliberate choices to normalize or denormalize, with reasoning.
295 
296| Decision | Approach | Reasoning |
297|---|---|---|
298| [e.g. Organisation name on users table?] | **Not denormalized** — always join to organisations | Avoid stale copies; org name changes are infrequent and joining is cheap |
299| [e.g. Status history] | **Not in this table** — separate `[table_a]_status_history` if needed | Current status is all that's needed for 99% of queries; history is auditing, not application data |
300| [e.g. JSONB `settings` column on organisations] | **Denormalized into JSONB** | Settings are read together; never queried by field; schema changes don't require migrations |
301| [e.g. Computed aggregate counts] | **Not stored** — computed at query time | Counts are small; maintaining a counter column requires careful locking; use `SELECT COUNT(*)` with the index |
302 
303---
304 
305## 7. Triggers and Automation
306 
307```sql
308-- Automatically update updated_at on any row modification
309CREATE OR REPLACE FUNCTION set_updated_at()
310RETURNS TRIGGER AS $$
311BEGIN
312 NEW.updated_at = now();
313 RETURN NEW;
314END;
315$$ LANGUAGE plpgsql;
316 
317-- Apply to all tables with updated_at
318CREATE TRIGGER users_updated_at
319 BEFORE UPDATE ON users
320 FOR EACH ROW EXECUTE FUNCTION set_updated_at();
321 
322CREATE TRIGGER [table_a]_updated_at
323 BEFORE UPDATE ON [table_a]
324 FOR EACH ROW EXECUTE FUNCTION set_updated_at();
325```
326 
327---
328 
329## 8. Migration Notes
330 
331If this schema is being introduced to an existing system, note the migration approach.
332 
333| Step | Description | Backward compatible | Risk |
334|---|---|---|---|
335| 1 | Create `organisations` table | Yes — additive | Low |
336| 2 | Create `users` table | Yes — additive | Low |
337| 3 | Backfill `org_id` on existing users | **Requires dual-write period** | Medium |
338| 4 | Add NOT NULL constraint on `org_id` | Requires backfill to be 100% complete | Medium |
339| 5 | Remove deprecated columns | Requires app code updated first | Low once app deployed |
340 
341**Backfill strategy:** [Describe how to handle existing data — batch size, rate limiting, validation queries]
342 
343**Rollback:** Each migration step should be independently reversible. See [database-migration-plan skill] for the full rollback procedure template.
344 
345---
346 
347## Quality Checks
348 
349- [ ] Every table has a primary key and a `created_at` column — no implicit ordering by row insertion
350- [ ] Every foreign key has a corresponding index — no missing FK indexes that would cause full table scans on joins
351- [ ] All TIMESTAMPTZ columns, not TIMESTAMP — timezone awareness is explicit
352- [ ] Soft-delete tables document the convention and where the filter is enforced (ORM scope, view, or query standard)
353- [ ] Every access pattern in the design has a supporting index or an explicit note that a full table scan is acceptable
354- [ ] JSONB columns are justified — not used as a substitute for proper schema design on queryable fields
355- [ ] Normalization decisions are documented with reasoning, not just stated
356- [ ] Migration notes address existing data if this is a schema change, not a greenfield schema
357 
358## Anti-Patterns
359 
360- [ ] Do not use JSONB columns as a substitute for proper relational schema design on fields that will be queried
361- [ ] Do not add indexes speculatively — every index must be justified by a specific access pattern
362- [ ] Do not omit timezone-awareness — use TIMESTAMPTZ, never plain TIMESTAMP
363- [ ] Do not design without documenting normalization decisions — future maintainers need the reasoning, not just the structure
364- [ ] Do not skip the access patterns section — schema without query patterns cannot be evaluated for correctness
365 

Discussion

Alternatives

Also in DatabasesSee all 533 in Development →