Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Expert database architecture and design guidance — schema/data modeling, ER diagrams, indexing, query optimization, partitioning, replication and high availability, transactions and locking, connection pooling, migrations, and multi-tenancy patterns across PostgreSQL, MySQL, MongoDB, and Redis. Use this whenever the user asks about designing a schema, a slow or N+1 query, choosing between SQL and NoSQL, sharding, read replicas, a database migration, or says something like "why is this query slow
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 158% | 0% |
| case-14 | ✓→✓ | = Same ✓ | 141% | 0% |
| case-15 | ✓→✓ | = Same ✓ | 152% | 0% |
| case-16 | ✓→✓ | = Same ✓ | 108% | 0% |
| case-12 | ✓→✓ | = Same ✓ | 188% | 0% |
Approach every database task as the engineer who has debugged a production outage caused by a missing index at 2am, and a data-loss incident caused by a migration with no rollback plan. The database is the most durable part of the system — it outlives every framework, every language, and every application rewrite. Design it with that lifespan in mind, not the lifespan of this sprint.
Before designing any schema or query, answer:
Design for the actual access patterns, not the entity relationships in isolation — a textbook-perfect 3NF schema that requires five joins for the one query that runs 10,000 times a second is not a well-designed schema.
| Use Case | Database | Why | |---|---|---| | Relational data with ACID transactions | PostgreSQL | Best-in-class open-source RDBMS; rich feature set (JSONB, window functions, extensions); strong community | | Simple relational, read-heavy, broad ecosystem | MySQL | Widely supported; performant for read-heavy workloads | | Document store, flexible schema, horizontal scale | MongoDB | Good for hierarchical, variable-structure data; native sharding | | Cache, session store, rate limiting, pub/sub | Redis | In-memory; sub-millisecond latency; rich data structures | | Full-text search | Elasticsearch | Inverted index; powerful query DSL; aggregations | | Time-series data | TimescaleDB / InfluxDB | Optimized for time-ordered inserts and range queries | | Graph relationships | Neo4j / Amazon Neptune | When relationship traversal is the primary access pattern, not a rare join |
Don't pick a database because it's fashionable. Pick it because its access patterns, consistency model, and operational characteristics match the requirements from Step 0. A surprising amount of "we need MongoDB for scale" turns out to be solvable with a well-indexed Postgres table and a read replica.
Core principles:
created_at / updated_at on every table — non-negotiable; you will need them for debugging, auditing, or a migration you haven't thought of yetNaming conventions:
user, order_item, payment_method)user_id, created_at, is_active){referenced_table}_id (user_id, order_id){table_a}_{table_b} (user_role, order_product)idx_{table}_{columns} (idx_user_email, idx_order_created_at)Many-to-many relationships resolve to a junction table with its own primary key and any relationship-specific attributes (e.g., quantity on order_product).
The single highest-leverage performance tool, and the most commonly misapplied.
Index by default:
WHERE, ORDER BY, or GROUP BYComposite indexes:
(user_id, created_at) serves queries filtering on user_id alone or user_id + created_at; it does not serve a query filtering on created_at aloneEXPLAIN / EXPLAIN ANALYZE that the planner is actually using the index you added — an unused index is pure write-cost with zero read benefitIndex costs — the part people forget:
pg_stat_user_indexes in Postgres) and drop indexes with zero scansCREATE INDEX ON orders (status) WHERE status = 'pending'Rule: add indexes for queries you actually run against production-scale data; remove ones the query planner never touches.
When a query is slow:
EXPLAIN ANALYZE — the actual execution plan, not the estimateANALYZEIN query. This is the single most common performance bug in application code that talks to a database, and it's invisible in local dev with 10 rows and catastrophic in prod with 10,000.LIMIT early — filter and paginate before joining where the query plan allows itSELECT * — pulling columns you don't need costs transfer bandwidth and blocks the planner from using a covering indexPagination:
OFFSET 10000 still scans and discards 10,000 rows before returning anythingWHERE id > :cursor ORDER BY id LIMIT 20) is O(1) regardless of page depth — use it for any dataset that will grow past a few thousand rowsTransactions aren't just "wrap it in BEGIN/COMMIT" — the isolation level decides what bugs are possible.
| Isolation Level | Prevents | Allows | Use when | |---|---|---|---| | Read Committed (Postgres default) | Dirty reads | Non-repeatable reads, phantom reads | Most application code; fine for single-row operations | | Repeatable Read | + Non-repeatable reads | Phantom reads (mostly, engine-dependent) | Reports/analytics needing a consistent snapshot mid-transaction | | Serializable | Everything | Nothing — behaves as if transactions ran one at a time | Financial operations, inventory decrements, anything where a race condition means real money or data loss |
Practical rules:
SELECT ... FOR UPDATE for pessimistic locking (safe under contention, costs throughput); optimistic locking via a version column for low-contention cases (cheaper, requires retry logic on conflict)tenant_id) — a bad shard key just relocates the hot-spot problem instead of solving it.Partition when a single table is too large to maintain efficiently — not before; partitioning adds real operational complexity.
Partitioning doesn't replace indexing — you still index within partitions. It helps with query pruning (skip irrelevant partitions), bulk deletes (detach + drop a partition instead of a slow DELETE), and maintenance (vacuum partitions independently).
Every database connection has real memory and CPU cost on the server side — opening a new connection per request exhausts the connection limit long before it exhausts application throughput.
SET session variables) — know which mode you're in before relying on thoseFlexible schema is a feature, not permission to be careless.
Redis is a data structure server, not just a cache — use the structure that fits:
| Pattern | Data Structure | Example | |---|---|---| | Cache | String (with TTL) | Session data, rendered HTML fragments | | Rate limiting | String (INCR + EXPIRE) | API calls per user per minute | | Leaderboard | Sorted Set | Top users by score | | Pub/Sub messaging | Pub/Sub | Real-time notifications | | Job queue | List (LPUSH/BRPOP) | Background task dispatch | | Distributed lock | String (SET NX PX) | Prevent duplicate job execution | | Feature flags | Hash | Per-user feature toggles |
Never use Redis as a primary database unless persistence (RDB snapshots or AOF) is explicitly configured and tested — by default, a restart loses everything in memory.
Cache invalidation: decide the strategy up front — TTL expiry (simple, tolerates staleness), write-through (cache updated on every write, always fresh, more write cost), or explicit invalidation on write (fresh, but a missed invalidation path is a hard-to-find bug). "Cache invalidation is one of the two hard problems" is a cliché because it's true — pick deliberately, don't default into it.
If the user is building a SaaS product, this decision shapes everything downstream:
| Pattern | Isolation | Cost | Use when | |---|---|---|---| | Shared schema, tenant_id column | Lowest — relies on every query filtering correctly | Cheapest to operate | Many small tenants, cost-sensitive | | Schema-per-tenant | Medium | Moderate | Mid-size tenant count, some compliance need for logical separation | | Database-per-tenant | Highest | Most expensive | Few large tenants, strict compliance/data-residency requirements |
Shared-schema is the most common choice but the riskiest to get wrong — a single missing WHERE tenant_id = ? is a cross-tenant data leak. Consider row-level security (Postgres RLS) as a database-enforced backstop rather than trusting every query in the codebase to remember the filter.
Every schema change is a migration. Every migration must be:
down script that restores the previous stateCREATE INDEX CONCURRENTLY in Postgres to avoid locking the table during index creationZero-downtime column rename (never do this in one migration against a live system with running app code):
DROP TABLE; migrations run under a separate, more-privileged roleSELECT * debugging queries and logsRLS) as a database-enforced tenant/ownership boundary, not just an application-layer checkRead migration-safety.md before planning a production schema or data migration.
EXPLAIN ANALYZEOther measured skills in the registry, with their headline benchmark lift.