Why LSM trees exist
B-trees write where the key lives. LSM trees refuse to do that — and that refusal is the whole point.
On this page
The picture version
Six pictures for a reader who has never thought about how a database writes to disk. The prose below fills in the seams the pictures skip.
1 · The problem
A firehose of tiny writes, each one landing somewhere different.
2 · The move
Stop filing. Everything new goes in one tray.
3 · When the tray fills
Staple it into a sorted booklet and put it on the shelf. Never open it again.
4 · Who pays
The writer got their convenience. The reader gets the bill.
5 · The night shift
Someone merges the booklets so the shelf never grows without limit.
6 · Keep this card
The whole thing on one index card.
Why it exists
Your watch records your heart rate roughly once a second and has been doing it for years. Open the fitness app and scroll back to a run last March: the chart draws instantly, even though the thing it’s reading from has swallowed tens of millions of tiny timestamped writes and is still swallowing one right now. That stream — a firehose of small, ever-newer key-value writes that you occasionally need to read a sorted slice out of — is the running example for this post.
A B-tree, the default index in most relational databases, writes a key roughly where that key already lives on disk. That’s ideal for a workload of mixed reads and writes. It’s the wrong shape for a firehose: each insert lands at a different spot in the tree, so the disk head — or the SSD’s flash translation layer — is constantly asked to go somewhere else and update a few bytes. Scattered small writes are far more expensive than sequential ones on every storage device this design targets — dramatically so on a spinning disk, still substantially on flash — and the firehose asks for nothing else.
LSM trees came out of refusing that bargain. The original 1996 paper by O’Neil, Cheng, Gawlick, and O’Neil — The Log-Structured Merge-Tree — was written for spinning disks, where a random seek meant physically moving an arm and waiting for the platter, and streaming the same number of bytes sequentially was cheaper by orders of magnitude. The idea: never update a key in place. Just append. Sort and merge later, in the background, when you can do it sequentially.
That choice is why LevelDB, RocksDB, Cassandra, ScyllaDB, HBase, Bigtable, and a good many time-series databases are LSM-based. It’s also why your Postgres tables are not. Different bargains, different workloads.
Why it matters now
Even on NVMe SSDs, where random I/O is much cheaper than it was in 1996, the LSM bargain still pays off — just for slightly different reasons. SSDs have an internal flash translation layer that dislikes small random writes, because flash can only be erased a whole block at a time, so a small overwrite means reading, modifying and rewriting a much larger region. Sequential appends line up with the erase-block grain instead of fighting it. The technique designed for spinning rust survived the move to flash almost unchanged.
Anything calling itself a “key-value store,” “time-series database,” or “embedded storage engine” plausibly has an LSM at the bottom. If you’ve ever wondered why your Cassandra cluster has a “compaction” knob, why RocksDB stalls under sustained write load, or why deleting a lot of data made a database use more disk — that’s the LSM showing through.
The short answer
LSM tree = in-memory sorted buffer + append-only sorted files on disk + background merge
Picture to keep: a desk where you never refile anything. New notes land in one tray; when the tray is full you staple it into a sorted booklet and put it on the shelf; a librarian comes by at night and merges old booklets into fewer, fatter ones. Like that desk, except the reader pays for your convenience: until the librarian has been, looking something up means checking every booklet on the shelf, newest first.
Writes hit a small in-memory structure (often a sorted skiplist), get journaled to a WAL for crash safety, and when the buffer fills, the whole thing is flushed to disk as one sorted file. Reads check the buffer, then the disk files newest-to-oldest. A background process keeps merging files so reads don’t have to check too many.
The trick: writes are always sequential, even when keys aren’t.
How it works
Build it from the failure, one constraint at a time, with the heart-rate firehose in view.
Naive attempt: just append every write to a log. This is the fastest possible thing a disk can do, and it completely solves the write problem — no seeks, ever. Why it breaks: reading. “What was my heart rate last March?” now means scanning the entire log from the beginning, because nothing is in any order.
Fix: keep the recent writes sorted in RAM. Incoming writes go into a sorted in-memory structure called the memtable — often a skiplist, because it takes concurrent inserts well. Sorting in RAM is cheap and involves no disk at all. Why it breaks: RAM is volatile and finite. A crash loses everything not yet on disk, and the watch has been writing for years.
Fix: a WAL for the crash, a flush for the size. Every write also goes to an append-only log on disk — one sequential append, no seek — purely so the memtable can be rebuilt after a crash. And when the memtable hits a size threshold (tens of MB, typically), it’s frozen and written out as a single immutable file: an SSTable. Writing it is cheap and sequential because the memtable was already sorted. That SSTable’s WAL can then be discarded. This is the whole bargain, and everything below is repair work on it.
Why it breaks: you now have hundreds of SSTables, each sorted internally, none sorted relative to the others. A single key could be in any of them, so a read has to consult files newest-to-oldest until it finds a hit — and a read for a key that doesn’t exist has to check all of them.
Fix: merge them in the background. A background process picks several SSTables, merge-sorts them (sequential reads, one sequential write), and produces fewer, larger files, discarding overwritten and deleted keys on the way. This is compaction, and it’s the piece that makes the whole design viable.
How those files get organised is the main axis along which real LSMs differ. Leveled compaction — LevelDB’s namesake, and RocksDB’s usual default — arranges SSTables into levels where level 0 is freshly flushed, level 1 is roughly 10x bigger, level 2 10x bigger again, and each level below 0 holds files with non-overlapping key ranges. Tiered compaction instead accumulates several similarly-sized files and merges them into one bigger file, doing less rewriting at the cost of more files to check on a read. The rough intuition for a leveled layout is that a point read touches about as many places as there are levels, which grows only logarithmically with your data — treat that as intuition, not a bound; the real cost also depends on how many overlapping files sit in level 0.
flowchart LR
W[Write] -->|append| WAL[(WAL on disk)]
W -->|insert| M[Memtable<br/>in-memory skiplist]
M -->|full: freeze + flush| L0[SSTables · L0]
L0 -->|compact: merge-sort| L1[SSTables · L1 ~10x]
L1 -->|compact| L2[SSTables · L2 ~100x]
Every arrow except the WAL append is sequential I/O — that is the entire bargain. The foreground write touches only RAM plus one log append; all the sorting and rewriting happens later, in the background, moving data rightward.
A read for key K does, in order:
- Check the memtable.
- Check the immutable memtable being flushed, if any.
- Check the on-disk files, newest first. In a leveled layout the files below level 0 have non-overlapping ranges, so each of those levels contributes at most one candidate file; level 0 files can overlap each other, so all of them are candidates.
Why step 3 still breaks: a read for a key that isn’t there — a heart-rate sample from a day the watch sat off the charger — walks every candidate file and finds nothing in each. Fix: put a summary in RAM that can rule a file out. Every SSTable ships with a Bloom filter: a few bits per key that answers “definitely not in this file” cheaply enough to skip the disk read entirely.
Now the fitness chart. Scrolling to last March is a range scan, and it’s cheap for a different reason than the point lookup: the data inside each SSTable is already sorted by key, so a time-ordered range is a contiguous run inside each file, and the read merges a handful of sorted runs rather than searching. Bloom filters are no help here — a filter answers “is this exact key present,” not “does this file overlap this range” — so range performance depends on how many files compaction has left to merge across. That’s the same knob again.
The bill arrives as write amplification and read amplification. A single logical write is re-written several times as compaction carries it down the levels — a rough rule of thumb for leveled RocksDB configurations puts this in the 10–30x range, but it swings wildly with workload and settings and no canonical source pins it down, so treat it as an order of magnitude, not a figure. A single logical read can fan out to multiple files. That is the LSM’s deal with the devil: trade background I/O and read complexity for very fast, very sequential foreground writes.
One more seam: deletes are tombstones, which means a delete is itself a write, and the space isn’t reclaimed until compaction reaches the tombstone and every older file still holding that key. Tuning compaction is genuinely hard; the RocksDB tuning guide is famously long. How much of that complexity is essential and how much is accidental is genuinely unsettled — active research on tiered versus leveled compaction suggests the design space is still open.
You started with LSM tree = in-memory sorted buffer + append-only sorted files + background merge. What did this post add? — + the amplification you pay for the merge. The three parts explain why writes are fast. They don’t explain why your Cassandra cluster stalls, why a delete makes the disk grow, or why the same data is written to disk five times. That’s compaction — the background merge isn’t a housekeeping detail bolted onto the design, it is the design’s cost, moved somewhere you don’t have to wait for it.
Check yourself
Before you go — a team stores session data in an LSM store and deletes each session when the user logs out. Disk usage climbs steadily even though the number of live sessions is flat. Where’s the disk going?
Answer
Into the deletes. A delete in an LSM is an append like any other — a tombstone written to the memtable, flushed to an SSTable, holding the position “this key is gone.” Until compaction merges the tombstone with every older SSTable that still contains that key, both the original value and its tombstone occupy disk. A workload that’s half deletes is a workload where compaction is doing double duty, and if it can’t keep up, the garbage accumulates faster than it’s collected. The general lesson worth carrying: in an LSM, nothing you do makes the disk smaller directly. Only compaction does, and compaction is a background process with a rate limit.
And one more — someone proposes moving a read-heavy user-profile table off Postgres and onto RocksDB “because LSMs are faster.” What would you ask them?
Answer
Which direction the amplification runs for their workload. LSMs buy sequential writes by paying read amplification and write amplification; profiles are read constantly and updated rarely, so they’d be paying the LSM’s bill without collecting on it. A B-tree lookup is a handful of page reads to a known location; an LSM lookup may probe several levels, and every one of those probes is work the B-tree didn’t do. “Faster” isn’t a property of a storage engine — it’s a property of a storage engine and a workload together, and the ratio of writes to reads is the first number to ask for.
Famous related terms
- B-tree —
B-tree = sorted tree of pages + in-place updates— the other major on-disk index, optimized for read-heavy workloads with mixed point and range queries. See b-tree-indexes. - WAL —
WAL = append-only log + "write here before touching real state"— the crash-safety layer underneath both LSMs and B-tree databases. See write-ahead-log. - SSTable —
SSTable ≈ immutable sorted file + index + Bloom filter— the on-disk unit an LSM flushes and merges. - Compaction —
compaction = pick SSTables + merge-sort them + drop dead keys— the background work that keeps reads from drowning. - Bloom filter —
Bloom filter = bit array + k hash functions— answers “is this key definitely not in this file?” in a few cache lines.
Going deeper
- O’Neil, Cheng, Gawlick, O’Neil — The Log-Structured Merge-Tree (LSM-Tree), 1996. Answers “what problem was this actually designed for?”, and the cost model the authors used is still the one that decides LSM-vs-B-tree today.
- Martin Kleppmann, Designing Data-Intensive Applications, chapter 3 — answers “which one should I use?” via the clearest LSM-vs-B-tree side-by-side I know of.
- The RocksDB wiki on compaction styles and tuning — the rabbit hole, answering “what knobs does a real LSM expose, and what does each one trade away?”