Database Basics
The database is the most consequential architectural decision in most systems. The choice of storage engine, data model, and replication strategy shapes everything downstream — from query patterns to scaling approach to operational complexity.
SQL vs NoSQL
The first fork in every data layer decision: relational or non-relational. The answer depends on your data model, consistency requirements, and scale targets.
| SQL (Relational) | NoSQL | |
|---|---|---|
| Data model | Tables with rows and columns, strict schema | Documents, key-value, wide-column, or graph — flexible schema |
| Consistency | ACID transactions | Usually eventual consistency (BASE) |
| Joins | First-class support | Typically avoided — denormalize instead |
| Scaling | Vertical primarily; horizontal via read replicas or sharding | Horizontal by design |
| Best for | Complex relationships, strong consistency, reporting | High write throughput, flexible schema, massive scale |
| Examples | PostgreSQL, MySQL, SQLite | MongoDB, Cassandra, DynamoDB, Redis |
NoSQL data models
- Document store (MongoDB, Firestore) — stores JSON-like documents. Good for nested, variable-shape data. Queries within a document are fast; cross-document joins are expensive.
- Key-value store (Redis, DynamoDB) — the simplest model: a key maps to a blob. Extremely fast for lookups by key; poor for range scans or complex queries.
- Wide-column store (Cassandra, HBase) — rows identified by a partition key, columns grouped into families. Optimised for writing and reading entire rows at high throughput across a distributed cluster.
- Graph database (Neo4j) — nodes and edges with properties. Native support for relationship traversal. Ideal for social graphs, recommendation engines, and fraud detection — anywhere the connections between data matter as much as the data itself.
ACID
ACID guarantees define what it means for a relational database transaction to be safe.
- Atomicity — a transaction either fully completes or fully rolls back. No partial writes. If a bank transfer debits one account but crashes before crediting the other, the debit is automatically undone.
- Consistency — a transaction takes the database from one valid state to another, never violating defined constraints (foreign keys, unique constraints, check constraints). An inconsistent write is rejected.
- Isolation — concurrent transactions execute as if they were sequential. One transaction cannot see another's uncommitted changes. The exact behaviour is configurable via isolation levels (read committed, repeatable read, serializable).
- Durability — once committed, a transaction survives crashes. Data is written to persistent storage via a Write-Ahead Log (WAL) before the commit is acknowledged.
BASE — the NoSQL alternative
Most NoSQL databases trade ACID for BASE semantics to achieve horizontal scale:
- Basically Available — the system responds to every request, even if some nodes are down.
- Soft state — the state of the system may change over time, even without new input, as replicas converge.
- Eventually consistent — given no new updates, all replicas will eventually reach the same state.
BASE is not a failure mode — it is a deliberate trade-off: accept stale reads in exchange for lower write latency and higher availability under partition.
Read Replicas
Most production workloads are read-heavy (often 80–90% reads). A read replica is an asynchronous copy of the primary database that serves read queries, offloading the primary so it can focus on writes.
How replication works
- The primary writes every committed transaction to a replication log (binlog in MySQL, WAL in PostgreSQL).
- Replicas tail that log and apply the same changes, typically with a lag of milliseconds to low seconds.
- Reads are directed to replicas; writes go to the primary. The application or a proxy (ProxySQL, PgBouncer) handles routing.
Replication lag and stale reads
Because replication is asynchronous, a read immediately after a write may return stale data. Strategies to handle this:
- Read-your-writes consistency — after a user performs a write, route their subsequent reads to the primary for a short window. Other users may still read from the replica.
- Monotonic reads — ensure a user always reads from the same replica so they never see data "go backwards."
- Strong consistency — route all reads to the primary. Eliminates stale reads but removes the load-balancing benefit of replicas entirely.
| Synchronous replication | Asynchronous replication | |
|---|---|---|
| Durability | Write confirmed only after replica ACKs | Write confirmed before replica ACKs |
| Data loss on primary failure | None — replica is fully up to date | Possible — replica may lag |
| Write latency | Higher — waits for replica round-trip | Lower — returns immediately |
| Common use | Financial systems, one synchronous standby | Most read replicas |
Indexes
Without an index, a database must scan every row in a table to find matches — an O(n) full table scan. An index pre-sorts a subset of the data so the database can jump directly to relevant rows. The trade-off: indexes speed up reads but slow down writes (every insert/update must also update the index) and consume additional storage.
B-Tree index
The default index type in PostgreSQL, MySQL, and most SQL databases. A balanced tree structure where each leaf node holds the indexed value and a pointer to the actual row. Supports:
- Exact lookups:
WHERE id = 42 - Range queries:
WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31' - Prefix matching:
WHERE name LIKE 'Jo%' - Ordering:
ORDER BY indexed_columncan use the index without a sort step
Hash index
Stores a hash of the indexed value. Extremely fast for exact-match lookups (O(1) average), but cannot support range queries or ordering. Rarely used as a standalone index type; more relevant inside the query engine for hash joins.
Composite indexes
An index on multiple columns: INDEX ON (user_id, created_at). The column order matters — the index is useful for queries that filter on user_id alone or on user_id + created_at together, but not on created_at alone (the leftmost-prefix rule). Design composite indexes around your most frequent query patterns.
When not to index
- Low-cardinality columns (boolean flags, enums with few values) — the index may not be selective enough to be worth using; the planner may ignore it.
- Write-heavy tables where the index maintenance overhead outweighs the read benefit.
- Small tables where a full scan is fast enough — the query planner will often choose a sequential scan over an index anyway.