Install any skill in seconds. Free to start, no credit card required.
Get Started Free →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.
.claude/skills/mohitagw15856-database-schema-design/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-07 | ✗→✓ | ▲ Improved | 64% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 115% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 99% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 170% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 106% | 0% |
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.
Ask for these if not already provided:
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]
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:
Out of scope:
┌───────────────────┐ ┌───────────────────────┐
│ 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 |
organisations1 sentence describing what this table stores and its role in the domain.]
sqlCREATE 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) |
users1 sentence describing what this table stores.]
sqlCREATE 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.]
sqlCREATE 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.]
sqlCREATE 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.
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):
sql-- 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:
Document the primary queries this schema is designed to serve. For each, show the query, the indexes used, and any caveats.
Frequency: Very high — called on every dashboard load Query:
sqlSELECT 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.
Frequency: High — every authentication attempt Query:
sqlSELECT 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.
Frequency: High Query:
sqlSELECT * 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.
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 |
sql-- 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();
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.
created_at column — no implicit ordering by row insertion| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-01 | fail→fail | 25,611 | 26,712 | +4% | 1 | 1 | 0% | 6,269 | 10,240 | +63% | 0 | 0 | — |
case-02 | fail→fail | 30,867 | 29,231 | -5% | 1 | 1 | 0% | 6,267 | 10,239 | +63% | 0 | 0 | — |
case-03 | fail→fail | 27,645 | 29,314 | +6% | 1 | 1 | 0% | 6,262 | 10,233 | +63% | 0 | 0 | — |
case-04 | pass→pass | 21,866 | 31,607 | +45% | 1 | 1 | 0% | 4,488 | 10,168 | +127% | 0 | 0 | — |
case-05 | pass→pass | 18,707 | 16,889 | -10% | 1 | 1 | 0% | 3,795 | 7,486 | +97% | 0 | 0 | — |
case-06 | pass→pass | 9,346 | 10,540 | +13% | 1 | 1 | 0% | 1,843 | 6,146 | +233% | 0 | 0 | — |
case-07 | fail→pass | 25,630 | 28,721 | +12% | 1 | 1 | 0% | 6,201 | 10,172 | +64% | 0 | 0 | — |
case-08 | fail→pass | 21,820 | 26,826 | +23% | 1 | 1 | 0% | 4,730 | 10,162 | +115% | 0 | 0 | — |
case-09 | fail→pass | 22,024 | 25,373 | +15% | 1 | 1 | 0% | 5,108 | 10,167 | +99% | 0 | 0 | — |
case-10 | fail→pass | 16,767 | 27,722 | +65% | 1 | 1 | 0% | 3,760 | 10,158 | +170% | 0 | 0 | — |
case-11 | pass→pass | 15,755 | 24,003 | +52% | 1 | 1 | 0% | 3,561 | 9,017 | +153% | 0 | 0 | — |
case-12 | fail→fail | 16,056 | 25,782 | +61% | 1 | 1 | 0% | 3,799 | 10,147 | +167% | 0 | 0 | — |
case-13 | pass→pass | 20,502 | 26,360 | +29% | 1 | 1 | 0% | 4,628 | 10,146 | +119% | 0 | 0 | — |
case-14 | fail→pass | 20,826 | 27,203 | +31% | 1 | 1 | 0% | 4,934 | 10,151 | +106% | 0 | 0 | — |
case-15 | pass→pass | 16,796 | 27,945 | +66% | 1 | 1 | 0% | 3,797 | 10,155 | +167% | 0 | 0 | — |
case-16 | fail→pass | 18,934 | 29,059 | +53% | 1 | 1 | 0% | 3,901 | 10,073 | +158% | 0 | 0 | — |
case-17 | fail→pass | 17,296 | 27,930 | +61% | 1 | 1 | 0% | 4,276 | 9,914 | +132% | 0 | 0 | — |
case-18 | fail→fail | 24,892 | 25,417 | +2% | 1 | 1 | 0% | 5,826 | 10,150 | +74% | 0 | 0 | — |
case-19 | fail→fail | 19,292 | 27,235 | +41% | 1 | 1 | 0% | 4,713 | 10,138 | +115% | 0 | 0 | — |
case-20 | fail→fail | 20,854 | 28,661 | +37% | 1 | 1 | 0% | 4,682 | 10,148 | +117% | 0 | 0 | — |
case-21 | fail→fail | 21,536 | 28,655 | +33% | 1 | 1 | 0% | 4,634 | 10,142 | +119% | 0 | 0 | — |
case-22 | fail→pass | 22,193 | 27,945 | +26% | 1 | 1 | 0% | 4,399 | 10,140 | +131% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 22 cases were attempted. The headline lift of +36 percentage points is the difference between those two pass rates over the 22 comparable cases. 2 cases got worse with the skill loaded, and they are included in that figure.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.