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.
| 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 insertionOther measured skills in the registry, with their headline benchmark lift.