--- name: database-patterns description: "DB schema design and query tuning: normalization, indexing, N+1, transactions, EXPLAIN. Triggers: schema, index, slow query, N+1, PostgreSQL, MySQL, EXPLAIN, deadlock, query plan." effort: medium user-invocable: false allowed-tools: Read --- # Database Patterns Skill ## ORM Selection | Scenario | ORM | |----------|-----| | Node.js, type-safe | Prisma | | Node.js, SQL-first | Drizzle | | Python, async | SQLAlchemy 2.0 | | Python, simple | SQLModel | | PHP | Doctrine, Eloquent | --- ## Schema Design ### Naming Conventions ```sql -- Tables: plural, snake_case CREATE TABLE user_profiles (...); -- Columns: snake_case user_id, created_at, is_active -- Indexes: idx_{table}_{columns} CREATE INDEX idx_users_email ON users(email); -- Foreign keys: fk_{table}_{ref_table} CONSTRAINT fk_orders_users FOREIGN KEY (user_id) REFERENCES users(id) ``` ### Common Patterns #### Soft Delete ```sql ALTER TABLE users ADD COLUMN deleted_at TIMESTAMP NULL; -- Query active records SELECT * FROM users WHERE deleted_at IS NULL; ``` #### Audit Columns ```sql created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, created_by UUID REFERENCES users(id), updated_by UUID REFERENCES users(id) ``` #### UUID vs Serial | Use Case | Type | |----------|------| | Internal only | SERIAL/BIGSERIAL | | External/distributed | UUID | | Human readable | SERIAL with prefix | --- ## Index Strategies ### When to Index - Foreign keys (always) - Columns in WHERE clauses - Columns in ORDER BY - Columns in JOIN conditions ### Index Types | Type | Use Case | |------|----------| | B-tree | Equality, range (default) | | Hash | Equality only | | GIN | Arrays, JSONB, full-text | | GiST | Geometric, full-text | | BRIN | Large sequential data | ### Composite Index Order ```sql -- Good: matches query pattern CREATE INDEX idx_orders_user_date ON orders(user_id, created_at); SELECT * FROM orders WHERE user_id = 1 AND created_at > '2024-01-01'; -- Index used for: -- WHERE user_id = 1 -- WHERE user_id = 1 AND created_at > ... -- Index NOT used for: -- WHERE created_at > '2024-01-01' (missing leading column) ``` --- ## Query Optimization ### Explain Analyze ```sql EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'test@example.com'; ``` ### Common Issues | Issue | Solution | |-------|----------| | Seq Scan on large table | Add index | | High row estimate | Update statistics | | Nested Loop on large sets | Consider hash join | | Sort in memory | Increase work_mem | ### N+1 Prevention ```python # Bad: N+1 for user in users: print(user.orders) # Query per user # Good: Eager loading users = User.query.options(joinedload(User.orders)).all() ``` --- ## Migration Best Practices ### Safe Migrations ```sql -- Add column (safe) ALTER TABLE users ADD COLUMN phone VARCHAR(20); -- Add NOT NULL column (safe pattern) ALTER TABLE users ADD COLUMN phone VARCHAR(20); UPDATE users SET phone = '' WHERE phone IS NULL; ALTER TABLE users ALTER COLUMN phone SET NOT NULL; -- Rename column (use application-level) -- 1. Add new column -- 2. Copy data -- 3. Update application -- 4. Remove old column ``` ### Migration Checklist - [ ] Tested on production-like data - [ ] Rollback script ready - [ ] No long locks on large tables - [ ] Indexes created concurrently - [ ] Application handles both states --- ## Connection Pooling ### PgBouncer Settings ```ini [pgbouncer] pool_mode = transaction max_client_conn = 1000 default_pool_size = 20 ``` ### Application Settings | Framework | Pool Size Formula | |-----------|------------------| | General | (cores * 2) + disk spindles | | Read-heavy | cores * 4 | | Write-heavy | cores * 2 | --- ## Vector Database (Qdrant) Patterns ### Client Setup ```python from qdrant_client import QdrantClient from qdrant_client.models import Distance, VectorParams, PointStruct # Sync client client = QdrantClient(host="localhost", port=6333) # Async client from qdrant_client import AsyncQdrantClient async_client = AsyncQdrantClient(host="localhost", port=6333) ``` ### Collection Management ```python # Create collection (single vector) client.create_collection( collection_name="documents", vectors_config=VectorParams(size=384, distance=Distance.COSINE) ) # Create collection (multi-vector) from qdrant_client.models import VectorParams client.create_collection( collection_name="multimodal", vectors_config={ "text": VectorParams(size=384, distance=Distance.COSINE), "image": VectorParams(size=512, distance=Distance.EUCLID), } ) ``` ### Upserting Vectors ```python # Single upsert client.upsert( collection_name="documents", points=[ PointStruct( id=1, vector=[0.1, 0.2, 0.3, ...], # 384-dim vector payload={"title": "Doc 1", "category": "tech"} ) ] ) # Batch upsert points = [ PointStruct(id=i, vector=vectors[i], payload=payloads[i]) for i in range(len(vectors)) ] client.upsert(collection_name="documents", points=points, batch_size=100) ``` ### Searching Vectors ```python from qdrant_client.models import Filter, FieldCondition, MatchValue # Basic search results = client.search( collection_name="documents", query_vector=[0.1, 0.2, ...], limit=10 ) # Search with filter results = client.search( collection_name="documents", query_vector=[0.1, 0.2, ...], query_filter=Filter( must=[ FieldCondition(key="category", match=MatchValue(value="tech")) ] ), limit=10, with_payload=True, score_threshold=0.7 ) # Search with range filter from qdrant_client.models import Range results = client.search( collection_name="documents", query_vector=query_vector, query_filter=Filter( must=[ FieldCondition(key="price", range=Range(gte=10, lte=100)) ] ), limit=10 ) ``` ### Payload Indexing ```python # Create payload index for faster filtering client.create_payload_index( collection_name="documents", field_name="category", field_schema="keyword" # or "integer", "float", "bool" ) ``` ### Best Practices | Aspect | Recommendation | |--------|----------------| | Batch Size | 100-1000 points per upsert | | Vector Dim | Match your embedding model (384, 768, 1536) | | Filters | Index frequently filtered fields | | Distance | COSINE for normalized, EUCLID for raw | | Sharding | Use for >1M vectors | ### Distance Metrics | Metric | Best For | Normalized | |--------|----------|------------| | COSINE | Text embeddings | Yes | | EUCLID | Image embeddings | No | | DOT | When vectors pre-normalized | Yes | ## Common Rationalizations | Excuse | Why It's Wrong | |--------|----------------| | "We'll add indexes later when it's slow" | Missing indexes on production tables cause outages, not slowdowns — index from design | | "The ORM handles performance" | ORMs generate queries, they don't optimize them — always check the query plan | | "NoSQL is faster" | NoSQL trades consistency for speed — if you need joins, use a relational DB | | "We don't need migrations, we'll update the schema directly" | Direct schema changes are irreversible and untestable — migrations are the safety net | | "One big table is simpler" | Denormalization without measurement creates update anomalies — normalize first, denormalize with data | ## Rules - **MUST** profile queries with `EXPLAIN (ANALYZE, BUFFERS)` before adding an index — indexes chosen by intuition miss the real hot path half the time - **MUST** design the schema around the dominant access pattern, not the logical entity graph — storage follows queries, not the other way round - **NEVER** write to production with raw SQL when a migration file fits — ad-hoc changes break rollback and audit - **NEVER** add a `SELECT *` in a loop — N+1 is the most common performance regression in code review - **CRITICAL**: every foreign key has an index on the referencing column. Postgres does not create one automatically, and `ON DELETE CASCADE` without the index causes full-table scans on delete. - **MANDATORY**: numeric IDs use `bigint` (or `bigserial`) in new tables unless there is a stated reason to cap at 2^31. Integer overflow on a growing table is a late, painful surprise. ## Gotchas - `EXPLAIN` without `ANALYZE` shows the planner's estimate, not the actual execution. A query plan that "looks good" with `EXPLAIN` can still be slow in practice — always use `ANALYZE` for real diagnosis. - ORM-generated queries often look efficient in one row but emit N+1 at scale. `prisma`, `sequelize`, `activerecord` all have "eager loading" switches that must be explicit — the default is lazy and bites under load. - Postgres transactions hold **row locks** until commit or rollback. A long-running transaction that reads rows another writer needs blocks progress silently. Investigate `pg_stat_activity` for `state=idle in transaction` when writes stall. - Index-only scans require both the query columns AND the filter to be in the index (or in the visibility map for heap tuples). Adding a single column to `WHERE` can demote an index-only scan to an index scan with a 10× slowdown. - MySQL implicit collation on JOIN across tables with different `utf8mb4` collations forces a row-by-row collation conversion — a 100× slowdown that shows as a full scan in the plan. Align collations during schema design. ## When NOT to Load - For **schema evolution** (zero-downtime, expand-contract, backfill) — use `/migration-patterns` - For running migrations as a task — use `/migrate` - For query-plan profiling and the four golden signals — use `/performance-profiling` - For vector/embedding-specific schema — this skill covers the mechanics; use `/rag-patterns` for retrieval design - For observability of DB metrics (slow query log, connection pool saturation) — use `/observability-patterns`