System Design Concepts

No fluff — visual, concise, interview-ready

💾 6 · STORAGE SYSTEMS

Database Internals

How databases actually store and find data — built from first principles, each problem leading to the next solution

Step 1 — The Naive Approach: A Plain File
🔍 Problem: How do we store data persistently and look it up efficiently? The obvious answer: write key-value pairs to a file. db set 1 "Alice" appends a line. db get 1 scans through. Simple — but two things break immediately.

❌ Problem A — Mutable Updates Shift Bytes

db.txt (raw bytes on disk): 001:Lorem␣ipsum\n 005:adipiscing\n 018:dolor␣sit\n UPDATE key 005 → "adipiscing elit vel mauris" (longer value!) 001:Lorem␣ipsum\n 005:adipiscing␣elit␣vel␣mauris\n 018 must shift right by 11 bytes With millions of rows, every update = massive byte-shuffle. O(n) per write.

❌ Problem B — Scanning is O(n)

GET key=018 — must scan every row: 001 ✗ 007 ✗ 012 ✗ 018 ✓ ... scanned N rows to find the last one 10M rows? That's seconds per lookup. Memory (RAM) ~nanoseconds per byte Disk (SSD/HDD) ~80× slower (SSD) or 10,000× (HDD)
Step 2 — Solution: Append-Only File (Never Mutate)
💡 Insight: Make records immutable. Every write — insert, update, or delete — is appended to the end. No byte-shifting. Updates become new records; deletes become tombstones (a record with a null value). To read, find the last occurrence of the key.

✅ Append-Only — Every Write is O(1)

After: set 1 "Lorem" → set 7 "foo" → del 7 → update 1 "Updated" 001:Lorem ipsum 007:foo bar 007:null ← tombstone (marks deletion) 001:Updated value ← latest wins Reading key 001: scan from end→start, return first match = "Updated value" Reading key 007: find tombstone → key is deleted All writes = sequential disk append = maximum I/O throughput ✓

❌ New Problem — File Grows Forever

After many updates, most records are stale: 001:version 1 ← stale 001:version 2 ← stale 007:foo ← stale 007:null (tombstone) ← stale 001:version 3 ← current 6 records, only 1 is live. File 6× larger than needed. At scale: terabytes of garbage eating your disk.
Step 3 — Solution: Segments + Compaction
💡 Insight: Once a file exceeds a size threshold, close it and start a new one. Old segments are then compacted in the background — all stale and tombstoned keys removed, only the latest value per key kept. Multiple compacted segments can be merged into one. The running database always reads the newest segment first.
Segment 1 (closed) 001:v1 (stale) 007:null (tombstone) 001:v2 (stale) 018:dolor sit (live) compact + merge Compacted 018:dolor sit only 66% smaller ✓ Segment 2 (active) 003:new entry 001:v3 (latest for 001) Reads check newest segment first → older → compacted Compaction runs in background thread — zero downtime
Step 4 — Speeding Up Reads: The Hash Index
🔍 Problem still unsolved: Even with segments, finding a key still requires scanning through records. With 10M records across multiple segments, this is too slow. 💡 Solution: Keep an in-memory hash table — a map from every key to its byte offset in the file. Every write updates both the file and the hash table. Lookups become: find offset in RAM (nanoseconds) → jump directly to that position on disk (one I/O, not N scans).

Hash Index — Key → Byte Offset

Hash Index (RAM) 001 → offset: 0 018 → offset: 47 007 → offset: 89 O(1) lookup in memory seek to offset File (Disk) offset 0: 001:Lorem ipsum offset 47: 018:dolor sit offset 89: 007:null (tomb) 1 disk seek, not a full scan

❌ Hash Index Limitations

1. All keys must fit in RAM. If you have 100M unique keys, the hash table must hold 100M entries in memory. You can't overflow to disk without losing the O(1) guarantee.

2. Range queries are blind. WHERE key BETWEEN 12 AND 18 — the hash table has no concept of order. You'd have to check every key from 12 to 18 individually or scan the whole file.

This is why: Simple key-value stores (Redis, DynamoDB for exact-key access) use hash indexes. Relational databases need something better for range queries → B+Tree.
Step 5 — Fixing Range Queries: Sort the Data
💡 Insight: If we keep the file sorted by key, range queries become a bounded scan — find the start key, read forward until end key, stop. And a sorted file lets us use a sparse index: instead of storing every key's offset, store only some keys as anchors. To find key 18: look up the nearest anchor ≤ 18, seek to that offset, scan forward. Fewer RAM entries, same fast lookup.
Dense Index (every key) vs Sparse Index (anchors only) Dense — stores all 001 → offset 0 007 → offset 15 010 → offset 29 014 → offset 44 018 → offset 60 5 entries in RAM vs Sparse — anchors only 001 → offset 0 (skip 007, 010...) 014 → offset 44 (fewer RAM entries) 2 entries in RAM Finding key 018 with sparse index: 1. Index has anchor 014 → offset 44 2. Seek to offset 44 on disk 3. Scan forward: 014... 016... 018 found! 4. Short scan, not full file scan Denser index = faster lookup, more RAM. Trade-off you control.
Step 6 — The Sorting Problem → LSM Tree
🔍 Problem: Keeping a file sorted on disk while appending new records is expensive — you'd need to re-sort on every insert. 💡 Solution: Sort in memory first, flush to disk in sorted batches. This is exactly what an LSM Tree does. New writes go to a sorted in-memory structure (Memtable, usually a skiplist). When it fills, it's flushed as a sorted, immutable file on disk called an SSTable. A WAL (Write-Ahead Log) is written simultaneously so no data is lost on crash.

LSM Tree — Write Path

Memory WAL (crash safety) Memtable (skiplist — sorted) flush when full Disk (SSTables) SSTable L0 (newest) SSTable L1 SSTable L2 (older) ↻ compaction merges levels Each SSTable has its own sparse index Deletes = tombstone records, removed on compaction

LSM Tree — Read Path & Trade-offs

Read: check newest → oldest, return first match 1. Memtable (in-memory, O(log N)) 2. SSTable L0 (bloom filter check first) 3. SSTable L1... L2... (increasing disk I/O) ✓ Writes: extremely fast (sequential append) ✗ Reads: may check multiple SSTables → Bloom filters prune unnecessary reads Used by: Cassandra, RocksDB, LevelDB, DynamoDB
Step 7 — The Read-Optimized Alternative: B+Tree
💡 Different trade-off: Instead of batching writes in memory and flushing, keep data always sorted on disk in a tree structure. Reads need at most O(log N) comparisons starting from the root. Updates mutate pages in-place (protected by a WAL for crash safety). This optimizes reads at the cost of more complex writes (page splits, rebalancing). A B+Tree stores data only in leaf nodes, which are linked — so range scans just traverse the leaf chain without going back up the tree.

B+Tree — Structure

7 16 internal node — keys only, no data 1 · 2 · 5 · 6 9 · 11 17 · 22 · 23 ← leaf nodes linked for range scans data lives only in leaves Postgres · MySQL InnoDB

B+Tree vs LSM Tree — When to Use Which

B+Tree LSM Tree Reads Fast ✓✓ OK (check levels) Writes OK (page splits) Very fast ✓✓ Range queries Excellent ✓✓ Good ✓ Write amplification Higher (page rewrites) Lower Space amplification Lower Higher (until compaction) Read-heavy → B+Tree · Write-heavy → LSM
The progression in one line: Plain file → append-only (fast writes) → segments+compaction (bounded size) → hash index (O(1) reads) → sorted+sparse index (range queries) → memtable+SSTable (sort without re-sorting on disk) = LSM Tree. Or: always-sorted pages + WAL = B+Tree.
8 Supporting Data Structures That Power Databases

1. Skiplist

In-memory · O(log N) search/insert · Multiple levels act as "express lanes" — skip nodes for fast traversal. Redis sorted sets (ZADD/ZRANGE).

2. Hash Index

0 1 2 as btc jobs twitter
In-memory · O(1) average · Bucket array + chaining for collisions. Most common in-memory index solution.

3. SSTable

Index file aaa offset:0 len:4 aab offset:4 len:7 zzz offset:3132 u b e r t w i t t e r m blob file (sorted data)
Disk-based · Sorted, immutable key-value file. Sorted order enables sparse indexes (anchor at key 10 → scan forward for 18) and fast range queries. Flushed from the Memtable. Each SSTable has its own sparse index. Building block of LSM trees.

4. WAL

Write WAL then DB Log first → apply later → replay on crash
Durability · Write-Ahead Log — every change logged before applying. On crash, replay to recover. Postgres, MySQL, etcd, Kafka.

5. LSM Tree

Memory Skiplist Disk SSTable 1 SSTable 2 SSTable 3
Memory+Disk · Writes go to sorted Memtable + WAL → flushed to immutable SSTables → background compaction merges and removes tombstones. Bloom filters avoid unnecessary disk reads. See the full build-up above ↑. Cassandra, RocksDB, LevelDB, DynamoDB.

6. B-Tree / B+Tree

7 16 1·2·5·6 9·11 17·22·23
Disk-based · Most popular DB index. Balanced tree, sorted keys, data in leaves. O(log N). Leaf nodes linked for range scans. Postgres, MySQL InnoDB.

7. Inverted Index

Index is today my "my name is" "What day?" "I bought"
Search · Maps each term → list of documents. BM25 ranking. Powers full-text search in Elasticsearch / Lucene.

8. R-Tree (Spatial)

Multi-dimensional · Hierarchical bounding boxes for spatial data. Nearest neighbor, geo range queries. PostGIS, MongoDB 2dsphere.
Schema: Normalization (3NF, eliminate redundancy, needs JOINs) vs Denormalization (duplicate for read speed, no JOINs). ACID vs BASE: ACID = strong consistency (SQL). BASE = Basically Available, Soft state, Eventually consistent (NoSQL).
DB Locks: Row-level (InnoDB default) · Table-level (MyISAM) · Intent locks (signal intent) · Advisory locks (app-level). MVCC — readers see snapshot, no read locks (Postgres, MySQL InnoDB).

Database Indexing

How indexes speed up reads by minimizing disk I/O — the difference between seconds and milliseconds

Index Types
Index TypeHowUse Case
ClusteredData physically sorted by index key (1 per table)Primary key lookups, range scans
Non-clusteredSeparate structure with pointers to data rowsSecondary lookups (email, name)
CompositeMulti-column index (leftmost prefix rule)WHERE a=1 AND b=2
CoveringAll query columns in the index — no table accessIndex-only scans (fastest reads)
PartialIndex only a subset of rows (WHERE active=true)Sparse data, smaller index size
GINGeneralized Inverted — multi-value keysFull-text search, JSONB, arrays (Postgres)
GiSTGeneralized Search Tree — spatial/rangeGeo queries (PostGIS), range types
BRINBlock Range — summary per block rangeLarge sequential data (time-series, logs)
SparseStores only anchor keys; scan forward from nearest match (requires sorted data)SSTable indexes, B+Tree internal nodes — controls RAM vs speed trade-off
How Indexing Makes Your DB Faster
The problem: Your data lives on disk, not RAM. Disk reads are 10,000× slower than memory. Worse — disk reads happen at the block level (e.g., 4KB or 8KB chunks). Even reading 1 byte loads the entire block. So the key to fast queries is: read as few disk blocks as possible. That's exactly what an index does — it's a small, sorted lookup table that tells the DB which blocks to read, so it skips the rest.

① The Data: Users Table on Disk

Each row = 200 bytes. Disk block = 600 bytes. So 3 rows fit per block.
id(4B) name(60B) age(4B) bio(128B) = 200B/row 1Alice23... 2Bob30... 3Carol25... 4Dave23... ... 100 rows total On disk (600B blocks): B1 B2 B3 ... B34 3 rows/block · ⌈100/3⌉ = 34 blocks

② The Index: A Tiny Sorted Lookup Table

Only stores the column you search + pointer to the row. Much smaller than data.
age(4B) id(4B) 231 234 253 302 ... 100 entries (sorted) 8B × 100 = 800B → 2 blocks IB1 IB2 vs 34 data blocks Index is 17× smaller than data!
Query: SELECT * FROM users WHERE age = 23
The DB needs to find all users aged 23. Without an index, it has no idea which blocks contain age=23 — so it reads every single block. With an index, it first checks the tiny index to learn exactly which row IDs match, then fetches only those specific blocks.

❌ Without Index — Full Table Scan

DB doesn't know where age=23 lives. Must load and check every block, one by one.
WHERE age=23 B1 B2 B3 B4 ... B33 B34 34 block reads Must read every block to find age=23 Slow — O(N) disk I/O

✓ With Index — Targeted Lookup

Index says "age=23 is at id=1 (block 1) and id=4 (block 2)." DB reads only those 2 blocks.
Step 1: scan index IB1 IB2 → found id=1, id=4 Step 2: fetch rows by id B1 B2 → got Alice, Dave 4 block reads (2 index + 2 data)

④ 100 Rows

No Idx
34 blocks
Indexed
4
8.5× speedup

At Scale: 1M Rows

No Idx
333K blocks
B+Tree
~4
~100,000× speedup

⑤ Key Insights

Block-level reads: Disk loads entire block even for 1 byte — so fewer blocks = faster
Index is tiny: Only stores search column + pointer (8B vs 200B/row) → 17× smaller
Index is sorted: Can binary search or stop early — no need to scan all entries
B+Tree: Real DBs use tree indexes → O(log N) block reads, not O(N)
Trade-off: Indexes speed up reads but slow down writes (must update index on every INSERT/UPDATE)

✓ When to Index

Columns in WHERE clauses (filter conditions)
Columns in JOIN ON (foreign keys)
Columns in ORDER BY / GROUP BY
High-cardinality columns (many unique values)
Read-heavy tables (more reads than writes)

⚠ When NOT to Index

Small tables (full scan is fast enough)
Write-heavy tables (index update overhead)
Low-cardinality columns (boolean, gender — few unique values)
Columns rarely used in queries
Too many indexes → slows INSERT/UPDATE/DELETE
Index → Clustered Index → Partitioning: Who Solves What
These three get confused constantly. Each solves a different problem. Walk the progression once and the distinction sticks — every step exists because the previous step left something unsolved.
Step 1 · Baseline

No Index

WHERE CustomerId=500 on 100M rows.

DB reads every page — page 1, 2, 3 … 1 million — checking each row.
Problem: finding a few rows scans the whole table
Step 2 · Add Index

Non-Clustered Index

Separate B-tree: key → row location.
Point query = fast (few pages).

But a range query returning 40M rows jumps around:
Jan 1 → Page 500
Jan 2 → Page 20
Jan 3 → Page 900
Problem: random I/O — index order ≠ physical order
Step 3 · Reorganize

Clustered Index

Doesn't add a map — reorders the table itself. Rows physically stored in key order.
Page 1: Jan 1, 2, 3
Page 2: Jan 4, 5, 6
Page 3: Jan 7, 8, 9
Solves: range scans are now sequential reads
Step 4 · Divide

Partitioning

Organized but still 10B rows. Split into physical segments; engine skips whole segments first.
2023 → skip
2024 → skip
2025 → search here
Solves: reduces how much data is even considered
Query ① Partitioning "What can I ignore entirely?" prune irrelevant segments ② Clustered Index "How are rows laid out?" sequential vs scattered ③ Non-Clustered "Where is this exact row?" pinpoint lookup
Three precision points people get wrong:
① A non-clustered index doesn't just mark a starting point — it holds an entry per matching key and can scan its own leaf pages. The expensive part is that each entry may point to a scattered table page.
② A clustered index doesn't replace the index — it changes storage organization. The table is stored in clustered-key order.
③ Partitioning doesn't make row fetches sequential (that's ①/②'s job) — it removes irrelevant chunks so there's less to consider at all.
Bottom line: An index is like a book's table of contents — instead of reading every page to find "Chapter 7", you look up the page number in the TOC and jump directly there. At scale, the difference between indexed and unindexed is seconds vs milliseconds.

Database Choice Guide

Now that internals and indexing make sense, the choice becomes clear — different engines are just different trade-offs on the read / write / range-query spectrum

Every row below is a consequence of the storage engine underneath. B+Tree engines (Postgres, MySQL) favour reads and range scans. LSM-tree engines (Cassandra, RocksDB) favour writes. Hash engines (Redis, DynamoDB) favour point lookups but can't range-scan. Inverted index engines (Elasticsearch) favour text matching. Pick the engine whose native shape matches your dominant access pattern.
NeedChooseWhy (engine underneath)Examples
ACID + complex joinsSQLB+Tree — strong consistency, referential integrity, range scansPostgres, MySQL, Aurora
High write throughputWide-ColumnLSM-tree — sequential writes, masterless, tunable consistencyCassandra, ScyllaDB
Flexible schemaDocumentB-tree over JSON — single-doc ACID, no migration neededMongoDB, Firestore
Sub-ms key lookupKey-ValueHash index — O(1) get/put, in-memory option, no range scansRedis, DynamoDB
Relationship traversalGraphIndex-free adjacency — O(1) hops, no joinsNeo4j, Neptune
Full-text searchSearch EngineInverted index + BM25 ranking, distributed across shardsElasticsearch, OpenSearch
Global ACID at scaleNewSQLB+Tree + distributed consensus (Paxos/Raft)Spanner, CockroachDB
Analytics (OLAP)ColumnarColumn-oriented storage — reads only the columns you aggregateBigQuery, ClickHouse, Apache Pinot
Most systems use several. A typical product runs Postgres as the source of truth, Redis for hot lookups, Elasticsearch for search, S3 for blobs, and a columnar warehouse for analytics. The question is rarely "which one database" — it's "which engine owns which access pattern."

SQL (PostgreSQL, MySQL)

ACID transactions + complex queries. The default choice when you need consistency

Guarantees: Atomicity — all or nothing. Consistency — constraints(PK, FK, UNIQUE) always enforced. Isolation — locks / MVCC prevent dirty reads. Durability — WAL survives crashes. These guarantees mean partial updates are impossible and committed data is never lost.
Postgres FeatureDetail
MVCCMulti-version — readers see snapshot, writers create new version. No read locks.
IndexesB-Tree (default), GIN (full-text/JSONB), GiST (geo), BRIN (large sequential)
JSONBBinary JSON with indexing — bridge SQL and document model
PartitioningRange/list/hash. Partition pruning speeds queries on large tables.
ExtensionsPostGIS (geo), TimescaleDB (time-series), Citus (distributed), pg_trgm (fuzzy)
Scaling: Read replicas (followers serve reads) · PgBouncer (connection pooling) · Citus/Vitess (sharding) · Vertical (bigger machine)
Limitations: Write bottleneck — single leader (~1-3K writes/sec). Resharding is painful. Rigid schema — ALTER on large tables can lock. Vertical scaling ceiling.
Real-world: Instagram — sharded Postgres. Stripe — Postgres for payments. Supabase — "Firebase on Postgres".

NoSQL

Horizontal scaling, flexible schema, tunable consistency

TypeExamplesGuaranteeUse Case
Key-ValueRedis, DynamoDBO(1) lookup, partition tolerantSessions, cache
DocumentMongoDB, FirestoreSingle-doc ACID, flexible schemaProfiles, catalogs
Wide-ColumnCassandra, HBaseWrite-optimized, tunable consistencyTime-series, IoT
GraphNeo4j, NeptuneO(1) edge traversalSocial, fraud, recommendations
Cassandra Guarantees: Masterless ring — no SPOF. Tunable consistency — QUORUM (W+R>N = strong) or ONE (fast, eventual). Anti-entropy: Read repair, Merkle trees, hinted handoff keep replicas in sync.
Limitations: No complex joins. No multi-row ACID. Eventual consistency by default. Must model around queries. Tombstone overhead.
Real-world: Discord — Cassandra (migrated to ScyllaDB). Netflix — Cassandra for viewing history. Uber — Cassandra for location data.

NewSQL

Global ACID at scale. Spanner (TrueTime + Paxos), CockroachDB (Raft), TiDB

Guarantees: Serializability globally — strongest isolation. SQL interface — familiar tools work. Trade-off: consensus latency (100ms+ multi-region) and 10x cost vs Postgres. Most apps don't need this.

Time-Series DBs

InfluxDB, Prometheus, TimescaleDB — optimized for append-heavy + range queries

Guarantees: High ingest (millions/sec). Auto-retention (TTL cleanup). Downsampling (1s→1min→1hr). Prometheus = pull-based scraping. TimescaleDB = Postgres extension (SQL for time-series).

Blob Storage (S3)

99.999999999% durability (11 nines). Cheap, infinite scale. The default for any unstructured data — images, videos, backups, logs.

Client upload image App Server generate pre-signed URL S3 / Blob Store PUT via pre-signed URL 11 nines durability CDN Edge serve to users Storage Tiers — Automatic Lifecycle Policies Standard $0.023/GB/mo frequent access 30d Infrequent (IA) $0.0125/GB/mo ~1×/month access 90d Glacier $0.004/GB/mo retrieval: minutes 1yr Deep Archive $0.00099/GB/mo retrieval: 12-48 hrs
FeatureHow It WorksUse Case
Pre-signed URLsServer generates time-limited signed URL → client uploads/downloads directly to S3 (no proxy)Large file uploads without loading app server
VersioningEvery overwrite creates a new version. Old versions retained until explicitly deleted.Accidental delete protection, audit trail
Multipart UploadSplit large files into chunks (5MB-5GB each), upload in parallel, assemble on S3Files >100MB, resumable uploads over flaky networks
Event NotificationsS3 triggers Lambda/SQS/SNS on PUT/DELETE eventsAuto-generate thumbnails, trigger transcoding pipeline
Cross-Region ReplicationAsync replicate objects to another region bucketDisaster recovery, compliance (data residency)
Guarantees: Strong read-after-write consistency (S3, since Dec 2020). 11 nines durability = lose 1 object per 100 billion stored per year. Infinite scale — no capacity planning needed. Partition prefixes for >5,500 GET/sec or >3,500 PUT/sec per prefix.
Design patterns: Media uploads — pre-signed URL → S3 → CDN. Data lake — Parquet/ORC files on S3 → query with Athena/Spark. Backups — DB snapshots → S3 → lifecycle to Glacier. Static hosting — HTML/CSS/JS on S3 + CloudFront.

Vector Databases

Store + ANN-search high-dimensional embeddings for AI/RAG

Doc / query Embed model OpenAI / BGE Vector [768] cosine sim Vector DB HNSW / IVF Top-K nearest neighbors return semantically similar items
DBNotes
Pinecone / Weaviate / MilvusManaged or OSS, native ANN
pgvectorPostgres extension — reuse existing DB
Qdrant / ChromaLightweight, dev-friendly
OpenSearch / ElasticHybrid lexical + vector
Use cases: semantic search, RAG, dedup, recommendations, image / audio similarity.
ANN Index Algorithms Compared
AlgorithmHow It WorksSpeedAccuracyMemoryBest For
HNSWHierarchical graph — navigate layers from coarse to fineVery fastHigh (95%+)High (in-memory graph)Low-latency serving, <1M vectors
IVF-PQCluster vectors (IVF) + compress with Product QuantizationFastGood (90%+)Low (compressed)Billions of vectors, cost-sensitive
Flat (brute force)Compare query against every vectorSlow (O(n))Perfect (100%)Full vectors in RAMSmall datasets (<100K), ground truth
ScaNNGoogle's anisotropic quantization + tree partitioningVery fastHighMediumGoogle-scale, TensorFlow ecosystem
Embedding dimensions: OpenAI text-embedding-3-small = 1536 dims. Cohere = 1024. BGE/E5 = 768. Image (CLIP) = 512. Each vector = dims × 4 bytes (float32). 1M vectors × 1536 dims = ~6GB RAM. Use PQ compression for 10-50× reduction.
RAG pattern: User query → embed → ANN search top-K docs → feed docs + query to LLM → grounded answer. Vector DB is the retrieval layer that makes LLMs factual.

Graph DB Deep Dive

Index-free adjacency — O(1) hops between connected nodes

Alice Bob Carol Post #42 FOLLOWS FOLLOWS LIKES LIKES
// Cypher: friends-of-friends who liked Post #42
MATCH (me:User {name:'Alice'})-[:FOLLOWS*1..2]->(f:User)
      -[:LIKES]->(p:Post {id:42})
RETURN DISTINCT f.name;
Use forAvoid for
Social graphs, fraud cycles, recsTabular OLAP / aggregates
Knowledge graphs, dependency treesHigh write throughput, BLOBs
Pathfinding, shortest-pathSimple CRUD apps (overkill)
Engines: Neo4j, Amazon Neptune, JanusGraph, ArangoDB (multi-model), TigerGraph (analytics-heavy).
Graph DB Internals — Why O(1) Traversal?
Node Record (fixed size) id: 42 labels: [:User] first_rel_ptr: → rel#7 first_prop_ptr: → prop#3 Direct pointer — no index lookup! Relationship Record id: 7 (FOLLOWS) start_node: → node#42 end_node: → node#99 next_rel_ptr: → rel#12 Linked list of relationships Index-Free Adjacency Each node stores direct pointers to its neighbors. Traversal = follow pointers O(1) per hop regardless of total graph size!
Graph vs Relational for relationships: SQL JOIN on 5-hop social query = 5 table scans, exponential cost. Graph DB = 5 pointer follows, constant cost per hop. At 1M nodes with avg 50 edges: SQL "friends of friends of friends" = seconds. Graph = milliseconds.
When NOT to use Graph DB: Simple CRUD with no relationships. High-volume writes (>100K/sec). Aggregations/analytics over all data (use columnar DB). Storing blobs/documents. If your queries don't traverse relationships, a graph DB adds complexity for no benefit.

Connection Pooling

Reuse expensive DB connections instead of opening per request

App pod 1 App pod 2 App pod 3 Pooler PgBouncer / HikariCP N reusable conns Postgres 100 max conns
PoolerStackMode
PgBouncerPostgressession / transaction / statement
HikariCPJava / JVMin-process
ProxySQLMySQLproxy + query routing
RDS ProxyAWSmanaged, IAM-aware
Serverless gotcha: 1000 Lambda containers × 5 conns each = 5000 — your DB caps at 100. Always front Lambda with a pooler.

Schema Migrations

Version-controlled, repeatable DB changes

ToolStack
FlywayJVM, polyglot SQL
LiquibaseJVM, XML/YAML changesets
AlembicPython / SQLAlchemy
Prisma MigrateNode / TS
Rails / Django Migrationsbuilt-in ORM
-- V20260510__add_user_email.sql
ALTER TABLE users
  ADD COLUMN email TEXT;
CREATE UNIQUE INDEX users_email_uq
  ON users (email);
Big-table pitfall: ALTER can take an exclusive lock for hours. Use pt-online-schema-change (MySQL) or gh-ost for zero-downtime changes (shadow table + chunked copy + atomic swap).
Expand–contract: add new column → dual-write → backfill → switch reads → drop old. Never break the running app.