1 LSM trees β write-optimized storage
Imagine you're taking notes super fast during a lecture. You don't carefully file each note into the right binder while the teacher is talking β that would be way too slow. Instead you scribble everything onto a fresh notepad as it comes, in order. Later, when you have a quiet moment, you tidy the scribbles into neat, sorted folders. An LSM tree works exactly like that: write fast and messy now, tidy up later.
An LSM tree (Log-Structured Merge-tree) is a way of organising data on disk that is built to make writes extremely fast. The name has three parts: log-structured (new data is appended like a log, never overwritten in place), merge (sorted pieces get merged together over time), and tree (the merged pieces form layers, loosely like a tree of levels). It is the storage engine behind RocksDB, Cassandra, LevelDB, ScyllaDB, and many others.
Recall the write-ahead log (WAL) from Session 14 (Logging & Recovery) β append-only, sequential, crash-safe. LSM trees take that "appending is cheap and safe" idea and make it the whole storage strategy, not just the recovery log. The HLD subject also covers LSM trees from a systems-design angle; here we keep the database-engine lens.
The three moving parts
An LSM engine has three core components. Let's define each.
- MemTable β an in-memory, sorted structure (often a skip list or balanced tree) that absorbs all incoming writes. It's fast because RAM is fast and it stays sorted so it can be flushed in order.
- SSTable (Sorted String Table) β an immutable file on disk holding keyβvalue pairs in sorted order. "Immutable" means once written it is never edited β only replaced by a later merge. This is the on-disk home of your data.
- Compaction β a background job that merges several SSTables into fewer, larger, still-sorted SSTables, throwing away stale/overwritten entries. This is the "tidy up later" step (covered in depth in Topic 4).
The write path, step by step
The magic is that every write is just an append: into the WAL (sequential disk write) and into the MemTable (in-memory). There is no hunting around the disk to find the exact spot to overwrite. Sequential writes are dramatically faster than random writes on both spinning disks and SSDs, which is why LSM engines shine at write-heavy workloads (logging, time-series, messaging, metrics).
You never edit an SSTable. To update a key, you just write the new value β it lands in the MemTable and a newer SSTable, and reads always take the newest version. To delete, you write a special marker called a tombstone that says "this key is gone." The old value still physically exists until compaction finally drops both it and the tombstone. So even deletes are writes!
The read path β and why it's harder
Reading is the trade-off. A key might live in the MemTable, or in any of several SSTables across several levels. A read must check them newest-first until it finds the key:
- Check the MemTable (RAM β fast).
- Check each SSTable, newest to oldest, until found.
To avoid touching every file, LSM engines use two tricks. A Bloom filter per SSTable β a tiny probabilistic structure that can say "this key is definitely not here" (so you skip the file entirely) or "maybe here." And a sparse index / block index that maps key ranges to file offsets so a lookup reads only one small block. Because each SSTable is sorted, a binary search inside the right block finishes the job.
Watch the same key get written, updated, and read. Operations arrive top to bottom:
# Writes arrive (each is just an append): PUT user:42 = "Ann" # goes to MemTable PUT user:99 = "Bo" # goes to MemTable # MemTable fills up β flushed to disk as SSTable-1 (sorted): SSTable-1 = { user:42 -> "Ann", user:99 -> "Bo" } PUT user:42 = "Annie" # NEW value, just appended to MemTable DELETE user:99 # writes a tombstone, not an erase # MemTable flushed again β SSTable-2 (newer): SSTable-2 = { user:42 -> "Annie", user:99 -> <tombstone> } # READ user:42 β check MemTable (miss), then SSTable-2 (newest) # β finds "Annie". Stops. Never even looks at SSTable-1. # READ user:99 β SSTable-2 has a tombstone β returns "not found". # Later, COMPACTION merges SSTable-1 + SSTable-2 into one: SSTable-3 = { user:42 -> "Annie" } # old "Ann" & the tombstone are dropped
Notice: nothing was ever overwritten in place. New facts simply shadow old ones, and compaction eventually reclaims the space. That is the whole LSM philosophy in one example.
LSM vs B+ tree β write-optimized vs read-optimized
Recall the B+ tree from our indexing session: a balanced tree kept sorted and updated in place. It's the classic engine behind PostgreSQL, MySQL/InnoDB, and most traditional databases. Here's the fundamental contrast:
| Aspect | B+ tree (read-optimized) | LSM tree (write-optimized) |
|---|---|---|
| Writes | Update a page in place β often a random disk write; may split pages. | Append to log + MemTable β sequential, very fast. |
| Point reads | One tree traversal, data lives in exactly one place. Predictable, fast. | May check MemTable + several SSTables (mitigated by Bloom filters). |
| Range scans | Excellent β leaves are linked in sorted order. | Good, but must merge sorted runs from multiple files. |
| Space | Compact; some fragmentation from page splits. | Temporary duplicates (old + new) until compaction; tombstones linger. |
| Best for | Read-heavy, mixed OLTP, predictable latency. | Write-heavy: logs, metrics, time-series, high ingest. |
LSM's "write now, tidy later" isn't free. Compaction re-writes data multiple times as it moves down the levels β this is write amplification. And one logical read may touch several files β read amplification. We unpack these trade-offs and how to tune them in Topic 4.
2 Row vs columnar storage
Imagine a big class register. You could store it as one card per student with all their info on it (name, age, grade, address) β grab a card and you know everything about one kid. Or you could store it as separate lists: a list of all names, a list of all ages, a list of all grades. If the teacher asks "what's the average age?", the second way is way easier β just read the ages list and ignore everything else. Row storage is one-card-per-kid; columnar storage is one-list-per-thing.
How a database physically lays bytes on disk decides what it's good at. There are two layouts.
- Row storage (row-oriented) β all the columns of one row are stored together, then the next row, and so on. Great when you want whole records.
- Columnar storage (column-oriented) β all the values of one column are stored together, then the next column. Great when you want one column across many rows.
OLTP vs OLAP β two different jobs
This connects to two workload types you should know cold:
| OLTP | OLAP | |
|---|---|---|
| Stands for | Online Transaction Processing | Online Analytical Processing |
| Typical query | "Fetch / update this one order." | "Sum revenue by month across 2 billion rows." |
| Touches | Few rows, all their columns. | Few columns, huge number of rows. |
| Pattern | Many small reads/writes. | Big scans & aggregations, mostly read-only. |
| Best layout | Row (e.g. PostgreSQL, MySQL). | Columnar (e.g. DuckDB, ClickHouse, Snowflake). |
Why analytics scans favour columnar β the picture
Imagine a sales table with columns id, customer, region,
amount and millions of rows. Here's how the two layouts store the same first three rows on disk:
# ROW layout β fields of a row are adjacent: [ 1, "Ann", "EU", 50 ] [ 2, "Bo", "US", 80 ] [ 3, "Cy", "EU", 20 ] ... # COLUMNAR layout β same field across rows is adjacent: id: [ 1, 2, 3, ... ] customer: [ "Ann", "Bo", "Cy", ... ] region: [ "EU", "US", "EU", ... ] amount: [ 50, 80, 20, ... ]
Now run SELECT SUM(amount) FROM sales;. The database reads data off disk in
fixed-size blocks. In the row layout, every block you read is stuffed with
id, customer, and region bytes
you don't care about β you drag the whole row in just to grab one number. In the columnar
layout, the amount values sit packed together, so you read only that
column and nothing else. On a billion-row table that's the difference between reading 4 columns' worth of
bytes and reading 1 β often a 5β50Γ reduction in I/O.
Analytics queries are narrow but deep: a few columns, billions of rows. Columnar layout reads only the columns a query touches, so it moves far less data off disk. That single property β I/O proportional to columns used, not columns stored β is the heart of every analytical database.
Bonus 1: compression gets way better
When values of the same type and meaning sit next to each other, they compress spectacularly β far better than a jumble of mixed-type row data. Two classic schemes:
- Run-Length Encoding (RLE) β replace a run of repeated values with
(value, count). A
regioncolumn that's sorted into long stretches of "EU" becomes("EU", 1_000_000)instead of a million copies. - Dictionary encoding β build a small dictionary of distinct values and
store cheap integer codes instead of the full values. Great for low-cardinality columns (few distinct
values) like
region,status, or country codes.
# Raw region column (lots of repetition): ["EU","EU","EU","US","US","EU","EU","EU","EU","ASIA"] # Step 1 β Dictionary encoding (map strings β small ints): dict = { 0: "EU", 1: "US", 2: "ASIA" } codes = [0,0,0,1,1,0,0,0,0,2] # tiny ints instead of strings # Step 2 β Run-Length Encode the codes: rle = [(0,3), (1,2), (0,4), (2,1)] # (value, run-length) # 10 strings β 4 little pairs. And a filter like # WHERE region = 'EU' becomes "where code == 0" β integer compares, fast.
Less disk read, less memory, and faster comparisons. Compression here isn't just about saving space β smaller data means fewer bytes to move, which directly speeds up the scan.
Bonus 2: vectorized execution
The second columnar superpower is how the data gets processed. Traditional row engines use the Volcano / tuple-at-a-time model: ask for one row, process it, ask for the next β one row through the whole pipeline at a time, with a function call per row. That's a lot of overhead per value.
Columnar engines instead use vectorized execution: process data in batches (vectors) of a few thousand values from one column at a time. One tight loop runs over a whole batch. This is cache-friendly, branch-predictor-friendly, and lets the CPU use SIMD (Single Instruction, Multiple Data β one CPU instruction adds many numbers at once). The overhead per row collapses.
Columnar storage hands you a contiguous array of one column's values β exactly the shape a tight vectorized loop (and SIMD) wants. Row storage would force you to gather one field out of each scattered row first. So columnar layout and vectorized execution reinforce each other: the storage format feeds the execution model. DuckDB (next topic) is built on precisely this pairing.
The flip side: fetching or updating one whole row in a columnar store means touching every column file separately, and inserting a single row is awkward when data is packed and compressed by column. For "give me order #12345" or "update this user's email," a row store wins easily. Pick the layout that matches the workload β there's no universally best answer.
3 DuckDB internals β the "SQLite for analytics"
Most databases are like a restaurant: you (the app) phone in your order, a separate kitchen (the database server) cooks it, and a waiter carries the food back and forth over the network. DuckDB is like having a tiny chef living inside your own kitchen β no phone call, no waiter, no separate building. You hand it a question and it answers right there in your program. And this chef specialises in big number-crunching meals (analytics), fast.
DuckDB is an open-source analytical database often described as "SQLite for analytics." SQLite proved you don't always need a big clientβserver database β sometimes you want a fast database that just runs inside your application as a library. SQLite is row-oriented and aimed at transactional, embedded use. DuckDB takes the same embedded idea but builds it for analytics instead. Three design pillars define it:
Pillar 1 β In-process (embedded)
DuckDB runs in the same process as your application β there is no separate server to install,
configure, start, or connect to over a network. You just import duckdb (or link
the C++ library) and go. Because there's no clientβserver boundary, query results don't have to be
serialised and shipped over a socket β DuckDB can hand data straight to your program's memory. For data
scientists this is huge: it reads directly from Pandas/Polars DataFrames, Parquet, and CSV files with
almost zero copying.
Pillar 2 β Columnar storage
From Topic 2, you already know why this matters: analytical queries scan a few columns over many rows, and columnar layout reads only those columns and compresses them well. DuckDB stores data column-by-column (and reads Parquet, itself a columnar file format, natively).
Pillar 3 β Vectorized execution
Also from Topic 2: DuckDB processes data in vectors (batches of ~1β2 thousand values) rather than one
row at a time, keeping the CPU cache hot and enabling SIMD. This is what makes its big
GROUP BY and aggregation queries fly on a single laptop.
It's not one trick β it's the combination: columnar storage means it reads only the needed columns (low I/O); compression means fewer bytes to move; vectorized execution means the CPU chews through those columns in tight, SIMD-friendly batches (low CPU overhead); and being in-process means no network or serialization tax. Each pillar removes a different bottleneck of an analytical scan.
import duckdb # No server, no setup, no loading step β query a file in place: result = duckdb.sql(""" SELECT region, SUM(amount) AS revenue FROM 'sales.parquet' -- read the columnar file directly WHERE year = 2025 GROUP BY region ORDER BY revenue DESC """) result.show() # It scans ONLY the region, amount, and year columns (columnar), # skipping every other column in the file, and processes them in # vectorized batches β all inside your Python process.
Notice you query a file as if it were a table, with no import step. DuckDB pushes the column
selection and the WHERE year = 2025 filter down into the Parquet reader, so it
never even reads the columns or row-groups it doesn't need.
Your written assignment for this session is to analyze DuckDB vs PostgreSQL. The point isn't "which is better" β it's "which fits which job," and why, grounded in everything above. A good answer frames it as OLAP vs OLTP:
- Architecture: PostgreSQL is a clientβserver system (a running server you connect to, supporting many concurrent users); DuckDB is in-process/embedded (a library inside one app).
- Storage: PostgreSQL is row-oriented (great for fetching/updating individual rows); DuckDB is columnar (great for scanning a few columns over millions of rows).
- Execution: PostgreSQL uses largely tuple-at-a-time execution tuned for OLTP; DuckDB uses vectorized execution tuned for OLAP scans.
- Concurrency & writes: PostgreSQL handles many concurrent transactional writers with full MVCC (recall our transactions/isolation sessions); DuckDB is built mainly for read-heavy analytics, typically a single writer.
- When to use each: PostgreSQL for the transactional system of record (orders, users, payments β many small reads/writes); DuckDB for fast local analytics over that data (dashboards, ad-hoc aggregation, data-science notebooks). They're complementary, not rivals β many teams use Postgres as the source of truth and DuckDB to crunch extracts of it.
Tip: run the same aggregation query on a large table in both and report the timings and why they differ β that turns the theory above into evidence.
4 RocksDB & compaction
Back to your messy lecture notes. Over the day you fill up notepad after notepad. If you never tidy them, finding one fact means flipping through every pad β slow. So you have a cleanup rule. One option: every evening, merge all small pads into a few big sorted binders (tidy, but a lot of recopying). Another: just stack pads of similar size and only merge when you have a whole pile of them (less recopying, but messier to search). Compaction is that cleanup rule, and the two options are leveled vs tiered.
RocksDB is Facebook/Meta's high-performance embedded key-value store, built on the LSM tree from Topic 1 (it grew out of Google's LevelDB). It's a library embedded in your app β much like DuckDB is embedded, but RocksDB is a transactional key-value engine, not an analytics engine. It powers the storage layer of countless systems (Kafka Streams, CockroachDB, TiKV, MySQL's MyRocks, and more). The single most important thing to tune in an LSM engine is compaction, so let's go deep.
Why compaction exists
Every MemTable flush creates a new SSTable. Without cleanup you'd accumulate thousands of small, overlapping files β reads would have to check too many of them, and stale values/tombstones would waste space forever. Compaction merges SSTables into fewer, larger, sorted files and drops obsolete entries. The strategy for doing this is a fundamental trade-off.
Leveled compaction
Leveled compaction (LevelDB's and RocksDB's default) organises SSTables into levels L0, L1, L2, β¦ Each level is roughly 10Γ larger than the one above. Crucially, within each level (from L1 down) the key ranges of SSTables do not overlap β each level is effectively one sorted run split into files. When a level gets too big, some of its data is merged down into the next level.
- Reads are good: below L0, a given key can be in at most one file per level, so a point lookup checks few files.
- Space is good: aggressive merging keeps duplicates/tombstones from piling up, so wasted space is low.
- Writes cost more: moving data down a level rewrites overlapping data repeatedly β higher write amplification.
Tiered compaction
Tiered compaction (also called size-tiered, the default in Cassandra) instead lets several SSTables of similar size accumulate in a tier, and only merges them once you have enough β producing one bigger file that joins the next tier. It rewrites data far less often.
- Writes are cheap: data is merged fewer times β low write amplification.
- Reads cost more: several overlapping files can hold the same key range, so a lookup may check more files β higher read amplification.
- Space costs more: multiple un-merged copies of overlapping data coexist β higher space amplification.
The three amplifications β the core trade-off
You cannot optimize all three at once; this is the famous RUM trade-off of LSM tuning. Define them precisely:
| Term | Definition | Plain English |
|---|---|---|
| Write amplification | Bytes actually written to disk Γ· bytes of logical data written by the user. | How many times your data gets re-written by compaction. |
| Read amplification | Number of disk reads (files/blocks checked) per logical read. | How many places you must look to answer one query. |
| Space amplification | Bytes stored on disk Γ· bytes of live logical data. | How much extra disk is wasted on stale copies & tombstones. |
Suppose you ingest data and it eventually settles across several SSTables. Compare the two strategies on the three amplifications (lower is better):
# LEVELED compaction (RocksDB default) write_amp = HIGH # data rewritten as it cascades L0βL1βL2β¦ read_amp = LOW # β€1 file per level below L0 for a key space_amp = LOW # duplicates aggressively merged away # β choose for READ-heavy & space-sensitive workloads # TIERED (size-tiered) compaction (Cassandra default) write_amp = LOW # data merged far fewer times read_amp = HIGH # several overlapping files may hold the key space_amp = HIGH # un-merged copies coexist # β choose for WRITE-heavy / high-ingest workloads
The rule of thumb: leveled trades write amplification for low read & space amplification; tiered trades read & space amplification for low write amplification. Pick based on whether your workload is read-heavy (leveled) or write-heavy (tiered).
Tuning knobs you'll actually meet
- Compaction style β leveled vs tiered (RocksDB calls the latter "universal"), the biggest lever, set by your read/write ratio.
- MemTable size & count β bigger MemTables = fewer, larger flushes = less compaction churn, at the cost of more RAM and longer recovery.
- Level size multiplier (default ~10) and L0 file trigger β control how aggressively data cascades down.
- Bloom filter bits per key β more bits = fewer wasted file reads (lower read amplification) for a little extra memory.
- Block size & compression (e.g. LZ4, Zstd per level) β trade CPU for disk I/O and space.
Comparison of engines
Pulling this session together, here's how the engines we've discussed line up:
| Engine | Core structure | Layout | Deploy | Sweet spot |
|---|---|---|---|---|
| RocksDB | LSM tree | Row / key-value | Embedded library | Write-heavy KV: state stores, metadata, high ingest. |
| Cassandra | LSM tree (tiered) | Wide-column (row-ish) | Distributed server | Massive write-heavy, horizontally scaled. |
| PostgreSQL / InnoDB | B+ tree | Row | Clientβserver | OLTP system of record, mixed read/write. |
| DuckDB | Vectorized engine | Columnar | Embedded library | Local OLAP analytics, data science. |
| ClickHouse | MergeTree (LSM-like) | Columnar | Clientβserver | Large-scale OLAP at server scale. |
Every choice here is a trade-off, not an upgrade. LSM buys fast writes by paying in read & write amplification. Columnar buys fast scans by making single-row OLTP awkward. Leveled compaction buys read performance by paying write amplification. Good database engineering is choosing which cost you can afford for your workload.
β Putting it all together
Every topic this session was really one lesson seen from different angles: the right data structure depends entirely on the workload. Here's the one-paragraph story tying it together:
Traditional databases use in-place B+ trees β read-optimized, great for OLTP. When you need to ingest writes blindingly fast, you flip the trade-off with an LSM tree: append to a MemTable, flush immutable SSTables, and clean up later via compaction β the design behind RocksDB and Cassandra, whose leveled vs tiered compaction lets you trade write / read / space amplification. When instead you need to scan a few columns over billions of rows (OLAP), you change the layout to columnar, which reads only the columns you touch, compresses them with RLE and dictionary encoding, and processes them with vectorized execution and SIMD. DuckDB packages exactly that β in-process, columnar, vectorized β into the "SQLite for analytics," the natural OLAP counterpart to a row-oriented OLTP database like PostgreSQL.
Quick self-check
Why are writes so fast in an LSM tree compared to a B+ tree?
LSM turns every write into a sequential append (to the WAL and the in-memory MemTable), which is much faster than a B+ tree's in-place, often-random page update. The cost is paid later, in the background, by compaction.
If SSTables are immutable, how does an LSM tree handle a delete?
It writes a tombstone β a marker saying the key is deleted. The old value still physically exists until compaction merges the files and finally drops both the old value and the tombstone.
Why does a query like SELECT SUM(amount) run faster on columnar storage?
Columnar storage packs each column's values together, so the engine reads only the
amount column off disk instead of dragging in every other column of every row.
Far less I/O, plus better compression and vectorized processing.
What do RLE and dictionary encoding do, and why does columnar help them?
RLE replaces runs of repeated values with (value, count); dictionary encoding replaces values with small integer codes from a lookup table. Columnar storage puts same-type, often-repetitive values next to each other, which is exactly the pattern these schemes compress best.
What are DuckDB's three design pillars, and why is each fast for analytics?
In-process (no network/server tax), columnar (reads only needed columns, compresses well), and vectorized execution (batch processing with SIMD, low per-row overhead). Together they remove the main bottlenecks of an analytical scan.
Compare leveled vs tiered compaction in terms of the three amplifications.
Leveled = low read & space amplification but high write amplification (good for read-heavy/space-sensitive). Tiered = low write amplification but high read & space amplification (good for write-heavy). You can't minimise all three at once β that's the RUM trade-off.
You're building the transactional store for an e-commerce checkout. Row or columnar? Why?
Row storage (e.g. PostgreSQL). Checkout is OLTP: many small reads/writes of whole records (one order, one user). Row layout keeps a record's fields together, which is ideal for fetching and updating individual rows.
π References & Further Reading
Class material
- π Original course notes / handout (source sheet) β open the shared class material for this session.
- π Class handout: "DBMS Session 15 β Modern Architectures".
Papers, docs & deep dives
- CMU 15-445 Database Systems β the gold-standard course; its storage-models and modern-systems lectures cover row vs columnar, LSM trees, and vectorized execution in depth.
- The Design and Implementation of Modern Column-Oriented Database Systems (Abadi et al.) β the definitive survey of columnar storage, compression, and vectorized execution.
- DuckDB Documentation β official docs for the in-process analytical database; great for the DuckDB-vs-PostgreSQL assignment.
- RocksDB Wiki β the authoritative reference on LSM internals, compaction styles (leveled/universal), and tuning.
- The Log-Structured Merge-Tree (O'Neil et al., 1996) β the original LSM tree paper that started it all.
- Querying Parquet with Precision using DuckDB β official write-up showing column/row-group pushdown in action.