Most production outages trace back to bad database decisions made early. Learn how senior engineers think about data modeling, indexing, sharding, and choosing between SQL and NoSQL.
Most production outages trace back to bad database decisions made early. Learn how senior engineers think about data modeling, indexing, sharding, and choosing between SQL and NoSQL.
Lesson outline
Your application code is easy to replace. Your database schema and the data it contains is not. Migrating 500 million rows from MySQL to Cassandra while keeping the service running is a multi-month engineering project. Getting the database choice right early saves enormous pain later.
The right database depends on: your data model (relational, document, graph, time-series?), your access patterns (reads vs writes, point lookups vs range scans?), your consistency requirements (ACID or eventual?), and your scale requirements (how many rows, how many writes per second?).
Start with PostgreSQL
If you are uncertain, start with PostgreSQL. It handles relational data, JSON documents (JSONB), full-text search, time-series (with TimescaleDB), and geospatial data. Migrate to specialized databases only when you hit a concrete limitation.
Relational databases (PostgreSQL, MySQL): ACID transactions, powerful joins, schema-enforced structure, strong consistency. Read replicas for horizontal read scaling. Vertical scaling for writes (or sharding — complex). Best for: financial transactions, user profiles, anything with complex relationships.
Document databases (MongoDB, Firestore): Schema-flexible, horizontal write scaling, denormalized data model. No joins — you embed related data. Best for: CMS content, product catalogs, user preferences, anything schema changes frequently.
Wide-column databases (Cassandra, DynamoDB): Extreme write throughput (100,000+ writes/sec per node), linear horizontal scaling, eventual consistency by default. No joins, no ad-hoc queries — you design tables around your queries. Best for: time-series data, IoT sensor readings, activity feeds, audit logs.
Search engines (Elasticsearch, OpenSearch): Inverted index optimized for full-text search and complex filtering. Not a primary database — use as a secondary index synced from your primary. Best for: product search, log analytics.
| Database | ACID | Horizontal Scale | Query Flexibility | Best Use Case |
|---|---|---|---|---|
| PostgreSQL | ✅ Full | Read replicas (easy), write (hard) | Excellent (SQL + JSONB) | Default choice, financial data |
| MongoDB | ✅ Multi-doc (v4+) | Sharding built-in | Good (no joins) | CMS, catalogs, flexible schema |
| Cassandra | ❌ Row-level only | Linear (petabytes) | Limited (by partition key) | High-write IoT, time-series |
| DynamoDB | ✅ Item-level | Managed, auto | Limited (GSI/LSI) | Serverless, AWS-native apps |
| Redis | ❌ (persistence optional) | Cluster mode | Data structure ops | Cache, sessions, leaderboards |
An index is a separate data structure that lets the database find rows without scanning every row. Without an index, `SELECT * FROM orders WHERE user_id = 123` on a 100M-row table does a full table scan — seconds of latency. With a B-tree index on `user_id`, it is milliseconds.
B-tree index: The default. Supports equality, range, prefix, and ORDER BY. Handles 99% of cases.
Composite index: Index on multiple columns `(user_id, created_at)`. Order matters — the leftmost prefix rule: `(user_id, created_at)` can serve queries filtering on `user_id` alone, or `user_id AND created_at`, but NOT `created_at` alone.
Partial index: Only index rows matching a condition. `CREATE INDEX ON orders(user_id) WHERE status = 'pending'`. Smaller, faster for targeted queries.
Covering index: Include extra columns in the index so the query never touches the main table (index-only scan). Dramatic performance improvement for hot queries.
Indexes have a write cost
Every write must update all indexes on that table. A table with 20 indexes will have 20x the write overhead vs no indexes. Index only what you query. Remove unused indexes — they are pure overhead.
1-- ❌ SLOW: Full table scan on 50M orders2EXPLAIN ANALYZEEXPLAIN ANALYZE actually executes the query — use on a staging DB3SELECT * FROM orders WHERE user_id = 42 AND status = 'pending';4-- Seq Scan on orders (cost=0.00..2M rows, actual=45sec)56-- ✅ FIX 1: Simple index7CREATE INDEX idx_orders_user ON orders(user_id);8-- Bitmap Index Scan (cost=100 rows, actual=3ms)910-- ✅ FIX 2: Composite index (even better for this query)11CREATE INDEX idx_orders_user_status ON orders(user_id, status);12-- Index Only Scan on idx_orders_user_status (cost=2 rows, actual=0.1ms)1314-- ✅ FIX 3: Partial index (smallest, fastest for pending-only queries)15CREATE INDEX idx_orders_user_pending16ON orders(user_id)17WHERE status = 'pending';1819-- ✅ COVERING INDEX: include columns the SELECT needs → no table heap read20CREATE INDEX idx_orders_coverPartial indexes are underused — they are smaller and faster for selective queries21ON orders(user_id, status)22INCLUDE (order_total, created_at);23-- Query hits only the index, never the table (fastest possible)2425-- 🔍 ALWAYS check your query plans26EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)27SELECT user_id, order_total, created_at28FROM orders29WHERE user_id = 42 AND status = 'pending';
Atomicity: All operations in a transaction succeed, or all fail. No partial writes. Example: transfer $100 between accounts — debit AND credit happen together or neither does.
Consistency: A transaction brings the database from one valid state to another. Constraints, foreign keys, and triggers are respected.
Isolation: Concurrent transactions see a consistent view of data. Isolation level controls how:
| Isolation Level | Dirty Read | Non-repeatable Read | Phantom Read | Use When |
|---|---|---|---|---|
| READ UNCOMMITTED | Possible | Possible | Possible | Never (analytics only) |
| READ COMMITTED | Prevented | Possible | Possible | Default in PostgreSQL/Oracle |
| REPEATABLE READ | Prevented | Prevented | Possible | Inventory checks, reports |
| SERIALIZABLE | Prevented | Prevented | Prevented | Financial transactions |
Durability: Once committed, data survives crashes. Achieved via write-ahead log (WAL) — changes are written to a log before the data pages, so after a crash the log can replay committed transactions.
Use SERIALIZABLE for money
Financial applications should use SERIALIZABLE isolation. READ COMMITTED (the default) allows phantom reads — a user's balance could appear different in two reads within the same "transaction", enabling double-spend attacks.
Database questions test whether you understand trade-offs, not whether you can recite SQL syntax.
Common questions:
Strong answers include:
Red flags:
Quick check · Database Architecture: Choosing and Scaling Your Data Layer
1 / 2
Key takeaways
From the books
Designing Data-Intensive Applications — Martin Kleppmann (2017)
Chapters 2-4: Data Models, Storage Engines, Encoding
Understanding how databases store data on disk (B-trees vs LSM-trees) explains why some databases are fast for reads and others for writes. This knowledge separates architects from implementers.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.