Database design skill

Designs database schemas, indexing strategies, query optimization, and migration patterns for SQL and NoSQL databases.

by CloudAI-X·MIT license·★ 1,416 Stars on the repo·GitHub ↗

Use now

Files of Database design

CloudAI-X/main1 file shown
SKILL.md
Show the full text395 lines

Database Design

When to Load
  • Trigger: Schema design, migrations, query optimization, indexing strategies, data modeling, N+1 fixes
  • Skip: No database work involved in the current task

Database Design Workflow

Copy this checklist and track progress:

Database Design Progress:
- [ ] Step 1: Identify entities and relationships
- [ ] Step 2: Normalize schema (3NF minimum)
- [ ] Step 3: Evaluate denormalization needs
- [ ] Step 4: Design indexes for query patterns
- [ ] Step 5: Write and optimize critical queries
- [ ] Step 6: Plan migration strategy
- [ ] Step 7: Configure connection pooling
- [ ] Step 8: Validate against anti-patterns checklist

Schema Design Principles

Normalization Forms
1NF: Atomic values, no repeating groups
2NF: 1NF + no partial dependencies (all non-key columns depend on full PK)
3NF: 2NF + no transitive dependencies (non-key columns don't depend on other non-key columns)
-- WRONG: Unnormalized
CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_name TEXT,
  customer_email TEXT,        -- duplicated across orders
  product1_name TEXT,         -- repeating groups
  product1_qty INT,
  product2_name TEXT,
  product2_qty INT
);

-- CORRECT: Normalized to 3NF
CREATE TABLE customers (
  id SERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  email TEXT UNIQUE NOT NULL
);

CREATE TABLE orders (
  id SERIAL PRIMARY KEY,
  customer_id INT REFERENCES customers(id),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE order_items (
  id SERIAL PRIMARY KEY,
  order_id INT REFERENCES orders(id),
  product_id INT REFERENCES products(id),
  quantity INT NOT NULL CHECK (quantity > 0)
);
When to Denormalize

Denormalize only when you have measured proof of performance issues:

-- Acceptable denormalization: precomputed counter to avoid COUNT(*)
ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;

-- Update via trigger or application code
CREATE FUNCTION update_comment_count() RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
  ELSIF TG_OP = 'DELETE' THEN
    UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
  END IF;
  RETURN NULL;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER comments_count AFTER INSERT OR DELETE ON comments
  FOR EACH ROW EXECUTE FUNCTION update_comment_count();

Indexing Strategy

Index Types and When to Use
B-tree (default):  Equality, range, sorting, LIKE 'prefix%'
Hash:              Equality only (rarely better than B-tree)
GIN:               Full-text search, JSONB, arrays
GiST:              Geometry, range types, full-text
BRIN:              Large tables with naturally ordered data (timestamps)
Composite Indexes
-- Column order matters: leftmost prefix rule
CREATE INDEX idx_users_status_created ON users (status, created_at);

-- This index supports:
--   WHERE status = 'active'                          -- YES
--   WHERE status = 'active' AND created_at > '2024'  -- YES
--   WHERE created_at > '2024'                        -- NO (skips first column)
Partial and Covering Indexes
-- Partial index: only index rows matching condition
CREATE INDEX idx_orders_pending ON orders (created_at)
  WHERE status = 'pending';  -- smaller index, faster lookups

-- Covering index: include columns to avoid table lookup
CREATE INDEX idx_users_email_covering ON users (email)
  INCLUDE (name, avatar_url);  -- index-only scan for profile lookups
Index Anti-patterns
-- WRONG: Index on low-cardinality column alone
CREATE INDEX idx_users_active ON users (is_active);  -- boolean = 2 values

-- WRONG: Too many indexes (slows writes)
-- Every INSERT/UPDATE must update ALL indexes

-- CORRECT: Composite index targeting actual queries
CREATE INDEX idx_users_active_created ON users (is_active, created_at DESC)
  WHERE is_active = true;

Query Optimization

Reading EXPLAIN Plans
EXPLAIN ANALYZE SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.status = 'active'
GROUP BY u.name;

-- Key things to look for:
-- Seq Scan         -> missing index (on large tables)
-- Nested Loop      -> fine for small sets, bad for large joins
-- Hash Join         -> good for large equi-joins
-- Sort             -> consider index to avoid sort
-- actual time      -> real execution time
-- rows             -> if estimated vs actual differ wildly, run ANALYZE
N+1 Query Detection and Prevention
# WRONG: N+1 queries (1 query for users + N queries for orders)
users = db.query(User).all()
for user in users:
    orders = db.query(Order).filter(Order.user_id == user.id).all()  # N queries!

# CORRECT: Eager loading with SQLAlchemy
users = db.query(User).options(joinedload(User.orders)).all()

# CORRECT: Batch query
user_ids = [u.id for u in users]
orders = db.query(Order).filter(Order.user_id.in_(user_ids)).all()
orders_by_user = defaultdict(list)
for order in orders:
    orders_by_user[order.user_id].append(order)
// WRONG: N+1 with Prisma
const users = await prisma.user.findMany();
for (const user of users) {
  const orders = await prisma.order.findMany({ where: { userId: user.id } }); // N+1!
}

// CORRECT: Include relation
const users = await prisma.user.findMany({
  include: { orders: true },
});

// CORRECT: Batch with findMany + in
const userIds = users.map((u) => u.id);
const orders = await prisma.order.findMany({
  where: { userId: { in: userIds } },
});
Pagination
-- WRONG: OFFSET pagination (rescans all skipped rows)
SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;

-- CORRECT: Cursor-based pagination (keyset)
SELECT * FROM posts
WHERE (created_at, id) < ('2024-01-15T10:30:00Z', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

Migration Patterns

Safe Migration Rules
1. Never rename a column in one step (add new, migrate data, drop old)
2. Never drop a column that's still read by running code
3. Add columns as nullable or with defaults
4. Create indexes CONCURRENTLY to avoid locking
5. Test rollback before deploying
Zero-Downtime Migration Example
-- Step 1: Add new column (brief ACCESS EXCLUSIVE lock — set lock_timeout and retry)
ALTER TABLE users ADD COLUMN display_name TEXT;

-- Step 2: Deploy code that writes to BOTH columns

-- Step 3: Backfill existing rows (do in batches)
UPDATE users SET display_name = name WHERE display_name IS NULL AND id BETWEEN 1 AND 10000;

-- Step 4: Deploy code that reads from new column
-- Step 5: Drop old column (after confirming no reads)
ALTER TABLE users DROP COLUMN name;
Index Creation
-- WRONG: Blocks writes on the table
CREATE INDEX idx_orders_user ON orders (user_id);

-- CORRECT: Non-blocking (PostgreSQL)
CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);
-- Cannot run inside a transaction block: most migration tools wrap migrations
-- in one, so disable the transaction for this migration

Connection Pooling

Rule of thumb: connections = (CPU cores * 2) + disk spindles
For most apps: 10-20 connections per application instance
# SQLAlchemy connection pool
engine = create_engine(
    DATABASE_URL,
    pool_size=10,          # maintained connections
    max_overflow=20,       # extra connections under load
    pool_timeout=30,       # seconds to wait for connection
    pool_recycle=1800,     # recycle connections every 30 min
    pool_pre_ping=True,    # verify connection before use
)
// Prisma datasource
// In schema.prisma:
// datasource db {
//   provider = "postgresql"
//   url      = env("DATABASE_URL")
// }
// Connection limit via URL: ?connection_limit=10&pool_timeout=30

ORM Best Practices

Select Only What You Need
# WRONG: Fetches all columns
users = db.query(User).all()

# CORRECT: Select specific columns
users = db.query(User.id, User.name).all()
// WRONG: Fetches everything
const users = await prisma.user.findMany();

// CORRECT: Select specific fields
const users = await prisma.user.findMany({
  select: { id: true, name: true, email: true },
});
Bulk Operations
# WRONG: Individual inserts in a loop
for item in items:
    db.add(Item(**item))
    db.commit()  # commit per item!

# CORRECT: Bulk insert
db.bulk_insert_mappings(Item, items)
db.commit()
// WRONG: Sequential creates
for (const item of items) {
  await prisma.item.create({ data: item });
}

// CORRECT: Batch create
await prisma.item.createMany({ data: items });

// CORRECT: Transaction for dependent operations
await prisma.user.create({
  data: { ...userData, profile: { create: profileData } },
});

NoSQL Design Patterns

Document Database (MongoDB)
// Design for access patterns, not normalization
// Embed when: 1:1, 1:few, data read together
// Reference when: 1:many, many:many, data grows unbounded

// WRONG: Normalizing in MongoDB like SQL
// users collection: { _id, name }
// addresses collection: { _id, userId, street }  // requires joins

// CORRECT: Embed bounded, co-accessed data
{
  _id: ObjectId("..."),
  name: "Alice",
  addresses: [
    { street: "123 Main St", city: "NYC", type: "home" },
    { street: "456 Work Ave", city: "NYC", type: "work" }
  ]
}

// CORRECT: Reference unbounded or independent data
// user: { _id, name }
// orders: { _id, userId, items: [...], total: 99.99 }  // index orders.userId
Key-Value / Redis Patterns
# Cache-aside pattern
1. Check cache for key
2. If miss, query database
3. Store result in cache with TTL
4. Return result

# Cache invalidation
- TTL-based: SET key value EX 3600 (1 hour)
- Event-based: Delete key on write
- Write-through: Update cache on every write

Common Anti-Patterns Summary

AVOID                              DO INSTEAD
-------------------------------------------------------------------
SELECT *                           SELECT specific columns
OFFSET pagination                  Cursor-based pagination
N+1 queries                        Eager load or batch queries
Indexing every column              Index based on query patterns
UUID v4 as primary key             UUID v7 or BIGSERIAL (better locality)
Storing money as FLOAT             Use DECIMAL / BIGINT (cents)
No foreign keys "for speed"        Use foreign keys (data integrity)
Giant migrations                   Small, reversible steps
No connection pooling              Always pool connections
Premature denormalization          Normalize first, denormalize with data
1---
2name: database-design
3description: Designs database schemas, indexing strategies, query optimization, and migration patterns for SQL and NoSQL databases. Use when designing tables, optimizing queries, fixing N+1 problems, planning migrations, or when asked about database performance, normalization, ORMs, or data modeling.
4---
5 
6# Database Design
7 
8### When to Load
9 
10- **Trigger**: Schema design, migrations, query optimization, indexing strategies, data modeling, N+1 fixes
11- **Skip**: No database work involved in the current task
12 
13## Database Design Workflow
14 
15Copy this checklist and track progress:
16 
17```
18Database Design Progress:
19- [ ] Step 1: Identify entities and relationships
20- [ ] Step 2: Normalize schema (3NF minimum)
21- [ ] Step 3: Evaluate denormalization needs
22- [ ] Step 4: Design indexes for query patterns
23- [ ] Step 5: Write and optimize critical queries
24- [ ] Step 6: Plan migration strategy
25- [ ] Step 7: Configure connection pooling
26- [ ] Step 8: Validate against anti-patterns checklist
27```
28 
29## Schema Design Principles
30 
31### Normalization Forms
32 
33```
341NF: Atomic values, no repeating groups
352NF: 1NF + no partial dependencies (all non-key columns depend on full PK)
363NF: 2NF + no transitive dependencies (non-key columns don't depend on other non-key columns)
37```
38 
39```sql
40-- WRONG: Unnormalized
41CREATE TABLE orders (
42 id SERIAL PRIMARY KEY,
43 customer_name TEXT,
44 customer_email TEXT, -- duplicated across orders
45 product1_name TEXT, -- repeating groups
46 product1_qty INT,
47 product2_name TEXT,
48 product2_qty INT
49);
50 
51-- CORRECT: Normalized to 3NF
52CREATE TABLE customers (
53 id SERIAL PRIMARY KEY,
54 name TEXT NOT NULL,
55 email TEXT UNIQUE NOT NULL
56);
57 
58CREATE TABLE orders (
59 id SERIAL PRIMARY KEY,
60 customer_id INT REFERENCES customers(id),
61 created_at TIMESTAMPTZ DEFAULT NOW()
62);
63 
64CREATE TABLE order_items (
65 id SERIAL PRIMARY KEY,
66 order_id INT REFERENCES orders(id),
67 product_id INT REFERENCES products(id),
68 quantity INT NOT NULL CHECK (quantity > 0)
69);
70```
71 
72### When to Denormalize
73 
74Denormalize only when you have measured proof of performance issues:
75 
76```sql
77-- Acceptable denormalization: precomputed counter to avoid COUNT(*)
78ALTER TABLE posts ADD COLUMN comment_count INT DEFAULT 0;
79 
80-- Update via trigger or application code
81CREATE FUNCTION update_comment_count() RETURNS TRIGGER AS $$
82BEGIN
83 IF TG_OP = 'INSERT' THEN
84 UPDATE posts SET comment_count = comment_count + 1 WHERE id = NEW.post_id;
85 ELSIF TG_OP = 'DELETE' THEN
86 UPDATE posts SET comment_count = comment_count - 1 WHERE id = OLD.post_id;
87 END IF;
88 RETURN NULL;
89END;
90$$ LANGUAGE plpgsql;
91 
92CREATE TRIGGER comments_count AFTER INSERT OR DELETE ON comments
93 FOR EACH ROW EXECUTE FUNCTION update_comment_count();
94```
95 
96## Indexing Strategy
97 
98### Index Types and When to Use
99 
100```
101B-tree (default): Equality, range, sorting, LIKE 'prefix%'
102Hash: Equality only (rarely better than B-tree)
103GIN: Full-text search, JSONB, arrays
104GiST: Geometry, range types, full-text
105BRIN: Large tables with naturally ordered data (timestamps)
106```
107 
108### Composite Indexes
109 
110```sql
111-- Column order matters: leftmost prefix rule
112CREATE INDEX idx_users_status_created ON users (status, created_at);
113 
114-- This index supports:
115-- WHERE status = 'active' -- YES
116-- WHERE status = 'active' AND created_at > '2024' -- YES
117-- WHERE created_at > '2024' -- NO (skips first column)
118```
119 
120### Partial and Covering Indexes
121 
122```sql
123-- Partial index: only index rows matching condition
124CREATE INDEX idx_orders_pending ON orders (created_at)
125 WHERE status = 'pending'; -- smaller index, faster lookups
126 
127-- Covering index: include columns to avoid table lookup
128CREATE INDEX idx_users_email_covering ON users (email)
129 INCLUDE (name, avatar_url); -- index-only scan for profile lookups
130```
131 
132### Index Anti-patterns
133 
134```sql
135-- WRONG: Index on low-cardinality column alone
136CREATE INDEX idx_users_active ON users (is_active); -- boolean = 2 values
137 
138-- WRONG: Too many indexes (slows writes)
139-- Every INSERT/UPDATE must update ALL indexes
140 
141-- CORRECT: Composite index targeting actual queries
142CREATE INDEX idx_users_active_created ON users (is_active, created_at DESC)
143 WHERE is_active = true;
144```
145 
146## Query Optimization
147 
148### Reading EXPLAIN Plans
149 
150```sql
151EXPLAIN ANALYZE SELECT u.name, COUNT(o.id)
152FROM users u
153JOIN orders o ON o.user_id = u.id
154WHERE u.status = 'active'
155GROUP BY u.name;
156 
157-- Key things to look for:
158-- Seq Scan -> missing index (on large tables)
159-- Nested Loop -> fine for small sets, bad for large joins
160-- Hash Join -> good for large equi-joins
161-- Sort -> consider index to avoid sort
162-- actual time -> real execution time
163-- rows -> if estimated vs actual differ wildly, run ANALYZE
164```
165 
166### N+1 Query Detection and Prevention
167 
168```python
169# WRONG: N+1 queries (1 query for users + N queries for orders)
170users = db.query(User).all()
171for user in users:
172 orders = db.query(Order).filter(Order.user_id == user.id).all() # N queries!
173 
174# CORRECT: Eager loading with SQLAlchemy
175users = db.query(User).options(joinedload(User.orders)).all()
176 
177# CORRECT: Batch query
178user_ids = [u.id for u in users]
179orders = db.query(Order).filter(Order.user_id.in_(user_ids)).all()
180orders_by_user = defaultdict(list)
181for order in orders:
182 orders_by_user[order.user_id].append(order)
183```
184 
185```javascript
186// WRONG: N+1 with Prisma
187const users = await prisma.user.findMany();
188for (const user of users) {
189 const orders = await prisma.order.findMany({ where: { userId: user.id } }); // N+1!
190}
191 
192// CORRECT: Include relation
193const users = await prisma.user.findMany({
194 include: { orders: true },
195});
196 
197// CORRECT: Batch with findMany + in
198const userIds = users.map((u) => u.id);
199const orders = await prisma.order.findMany({
200 where: { userId: { in: userIds } },
201});
202```
203 
204### Pagination
205 
206```sql
207-- WRONG: OFFSET pagination (rescans all skipped rows)
208SELECT * FROM posts ORDER BY created_at DESC LIMIT 20 OFFSET 10000;
209 
210-- CORRECT: Cursor-based pagination (keyset)
211SELECT * FROM posts
212WHERE (created_at, id) < ('2024-01-15T10:30:00Z', 12345)
213ORDER BY created_at DESC, id DESC
214LIMIT 20;
215```
216 
217## Migration Patterns
218 
219### Safe Migration Rules
220 
221```
2221. Never rename a column in one step (add new, migrate data, drop old)
2232. Never drop a column that's still read by running code
2243. Add columns as nullable or with defaults
2254. Create indexes CONCURRENTLY to avoid locking
2265. Test rollback before deploying
227```
228 
229### Zero-Downtime Migration Example
230 
231```sql
232-- Step 1: Add new column (brief ACCESS EXCLUSIVE lock — set lock_timeout and retry)
233ALTER TABLE users ADD COLUMN display_name TEXT;
234 
235-- Step 2: Deploy code that writes to BOTH columns
236 
237-- Step 3: Backfill existing rows (do in batches)
238UPDATE users SET display_name = name WHERE display_name IS NULL AND id BETWEEN 1 AND 10000;
239 
240-- Step 4: Deploy code that reads from new column
241-- Step 5: Drop old column (after confirming no reads)
242ALTER TABLE users DROP COLUMN name;
243```
244 
245### Index Creation
246 
247```sql
248-- WRONG: Blocks writes on the table
249CREATE INDEX idx_orders_user ON orders (user_id);
250 
251-- CORRECT: Non-blocking (PostgreSQL)
252CREATE INDEX CONCURRENTLY idx_orders_user ON orders (user_id);
253-- Cannot run inside a transaction block: most migration tools wrap migrations
254-- in one, so disable the transaction for this migration
255```
256 
257## Connection Pooling
258 
259```
260Rule of thumb: connections = (CPU cores * 2) + disk spindles
261For most apps: 10-20 connections per application instance
262```
263 
264```python
265# SQLAlchemy connection pool
266engine = create_engine(
267 DATABASE_URL,
268 pool_size=10, # maintained connections
269 max_overflow=20, # extra connections under load
270 pool_timeout=30, # seconds to wait for connection
271 pool_recycle=1800, # recycle connections every 30 min
272 pool_pre_ping=True, # verify connection before use
273)
274```
275 
276```javascript
277// Prisma datasource
278// In schema.prisma:
279// datasource db {
280// provider = "postgresql"
281// url = env("DATABASE_URL")
282// }
283// Connection limit via URL: ?connection_limit=10&pool_timeout=30
284```
285 
286## ORM Best Practices
287 
288### Select Only What You Need
289 
290```python
291# WRONG: Fetches all columns
292users = db.query(User).all()
293 
294# CORRECT: Select specific columns
295users = db.query(User.id, User.name).all()
296```
297 
298```javascript
299// WRONG: Fetches everything
300const users = await prisma.user.findMany();
301 
302// CORRECT: Select specific fields
303const users = await prisma.user.findMany({
304 select: { id: true, name: true, email: true },
305});
306```
307 
308### Bulk Operations
309 
310```python
311# WRONG: Individual inserts in a loop
312for item in items:
313 db.add(Item(**item))
314 db.commit() # commit per item!
315 
316# CORRECT: Bulk insert
317db.bulk_insert_mappings(Item, items)
318db.commit()
319```
320 
321```javascript
322// WRONG: Sequential creates
323for (const item of items) {
324 await prisma.item.create({ data: item });
325}
326 
327// CORRECT: Batch create
328await prisma.item.createMany({ data: items });
329 
330// CORRECT: Transaction for dependent operations
331await prisma.user.create({
332 data: { ...userData, profile: { create: profileData } },
333});
334```
335 
336## NoSQL Design Patterns
337 
338### Document Database (MongoDB)
339 
340```javascript
341// Design for access patterns, not normalization
342// Embed when: 1:1, 1:few, data read together
343// Reference when: 1:many, many:many, data grows unbounded
344 
345// WRONG: Normalizing in MongoDB like SQL
346// users collection: { _id, name }
347// addresses collection: { _id, userId, street } // requires joins
348 
349// CORRECT: Embed bounded, co-accessed data
350{
351 _id: ObjectId("..."),
352 name: "Alice",
353 addresses: [
354 { street: "123 Main St", city: "NYC", type: "home" },
355 { street: "456 Work Ave", city: "NYC", type: "work" }
356 ]
357}
358 
359// CORRECT: Reference unbounded or independent data
360// user: { _id, name }
361// orders: { _id, userId, items: [...], total: 99.99 } // index orders.userId
362```
363 
364### Key-Value / Redis Patterns
365 
366```
367# Cache-aside pattern
3681. Check cache for key
3692. If miss, query database
3703. Store result in cache with TTL
3714. Return result
372 
373# Cache invalidation
374- TTL-based: SET key value EX 3600 (1 hour)
375- Event-based: Delete key on write
376- Write-through: Update cache on every write
377```
378 
379## Common Anti-Patterns Summary
380 
381```
382AVOID DO INSTEAD
383-------------------------------------------------------------------
384SELECT * SELECT specific columns
385OFFSET pagination Cursor-based pagination
386N+1 queries Eager load or batch queries
387Indexing every column Index based on query patterns
388UUID v4 as primary key UUID v7 or BIGSERIAL (better locality)
389Storing money as FLOAT Use DECIMAL / BIGINT (cents)
390No foreign keys "for speed" Use foreign keys (data integrity)
391Giant migrations Small, reversible steps
392No connection pooling Always pool connections
393Premature denormalization Normalize first, denormalize with data
394```
395 

Discussion

Alternatives

Postgres MCP ProPostgres server with health checks, index tuning and explain plans, with a restricted read-only mode for production.Coding · MITSupabase postgres best practicesPostgres best practices maintained by Supabase, for Postgres running anywhere. Load this skill BEFORE writing or changing anything that lives in a Postgres database: creating or altering tables and columns (including choosing column types), schema design, migrations and declarative schema files, RLS policies and the tests that verify them, indexes, triggers, database functions, queues and scheduled jobs (pg_cron, pgmq), vector/semantic search (pgvector), and restoring dumps (pg_restore) or importing data. Also load it when diagnosing slow queries, high CPU, timeouts, EXPLAIN plans, connection exhaustion, locking, bloat, or rows visible to the wrong user or tenant. This is not just a performance guide — schema, migration, security, and SQL authoring tasks need these rules too, even for a one-column change or a single query.Coding · MITDatabase migrationExecute database migrations across ORMs and platforms with zero-downtime strategies, data transformation, and rollback procedures. Use when migrating databases, changing schemas, performing data transformations, or implementing zero-downtime deployment strategies.Coding · MITVector index tuningOptimize vector index performance for latency, recall, and memory. Use when tuning HNSW parameters, selecting quantization strategies, or scaling vector search infrastructure.Data & AI · MIT