LSM versus B-tree write amplification, derived and measured
Run a real systemNeeds Docker
Problem
Write amplification is the bytes a store writes to the device for each byte of user data. A B-tree updates a page in place, so a small row that changes costs a whole page: amplification about page size ÷ row size. An LSM tree never updates in place; it writes rows into sorted files and rewrites them as compaction merges levels, so each row is rewritten about T times per level, roughly T × L in total for a leveled tree. The two depend on different things: the B-tree's cost falls as rows grow towards a page; the LSM's stays the same whatever the row size, because the row is copied at every level.
First compute by hand: for 200-byte rows and 16 KB pages, page/row ≈ 80; for 8 KB rows, 2. For an LSM with T = 10 and N/M = 1,000, leveled T × L = 30. Then measure with RocksDB and a B-tree store (LMDB): write small rows, then 8 KB rows, and compute bytes written to the device ÷ bytes of user data from the process's I/O counters. Then enable RocksDB's blob files (key-value separation) for the large rows.
The run is scaled to a laptop (5 million 100-byte rows and 200,000 8 KB rows, inside a Linux container so that /proc/<pid>/io exists).
The question. Which store writes fewer device bytes per user byte for small rows, which for 8 KB rows, and what do blob files change?
Predict
Expect the LSM to win for small rows and lose for 8 KB rows. With blob files for the large rows, the LSM's amplification should fall.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
lsm-versus-b-tree-write-amplification.py # /// script # requires-python = ">=3.10" # dependencies = ["rocksdict", "lmdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """LSM against B-tree: write amplification. Bytes written to the device / bytes of user data, for RocksDB (an LSM) and LMDB (a B-tree), with small rows and with 8 KB rows; then RocksDB with key-value separation (blob files) for the large rows. Device bytes come from /proc/self/io (Linux), so this lab runs in a Linux container. """ import argparse, json, os, random, shutil, tempfile p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--small-rows", type=int, default=5_000_000, help="Small rows (the chapter uses 50 million).") p.add_argument("--small-bytes", type=int, default=100, help="Value size of a small row.") p.add_argument("--large-rows", type=int, default=200_000, help="Large rows (the chapter uses 1 million).") p.add_argument("--large-bytes", type=int, default=8192, help="Value size of a large row.") p.add_argument("--seed", type=int, default=0, help="Random seed.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() random.seed(args.seed) def written(): for line in open("/proc/self/io"): if line.startswith("write_bytes"): return int(line.split()[1]) def rocks(path, n, size, blob=False): from rocksdict import Rdict, Options, WriteOptions o = Options(); o.create_if_missing(True) if blob: o.set_enable_blob_files(True); o.set_min_blob_size(1024) db = Rdict(path, o) for i in range(n): db[random.randbytes(16)] = os.urandom(size) # random keys: real compaction work db.flush(); db.compact_range(None, None); db.close() def lmdb_store(path, n, size): import lmdb env = lmdb.open(path, map_size=64 << 30, sync=True) for start in range(0, n, 1000): # one transaction per 1,000 rows with env.begin(write=True) as tx: for i in range(start, min(start + 1000, n)): tx.put(random.randbytes(16), os.urandom(size)) env.sync(); env.close() rows = [] for label, fn, n, size, kw in (("RocksDB (LSM), small rows", rocks, args.small_rows, args.small_bytes, {}), ("LMDB (B-tree), small rows", lmdb_store, args.small_rows, args.small_bytes, {}), ("RocksDB (LSM), 8 KB rows", rocks, args.large_rows, args.large_bytes, {}), ("LMDB (B-tree), 8 KB rows", lmdb_store, args.large_rows, args.large_bytes, {}), ("RocksDB + blob files, 8 KB rows", rocks, args.large_rows, args.large_bytes, {"blob": True})): d = tempfile.mkdtemp(dir="/tmp") os.sync(); before = written() fn(d, n, size, **kw) os.sync(); amp = (written() - before) / (n * (size + 16)) rows.append({"store": label, "write_amplification": amp}); print(f"{label:<34} write amplification {amp:6.1f}x") shutil.rmtree(d) if args.json: with open(args.json, "w") as f: json.dump({"tables": {"stores": rows}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run lsm-versus-b-tree-write-amplification.py uv run lsm-versus-b-tree-write-amplification.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --small-rows5,000,000 | Small rows (the chapter uses 50 million). |
| --small-bytes100 | Value size of a small row. |
| --large-rows200,000 | Large rows (the chapter uses 1 million). |
| --large-bytes8192 | Value size of a large row. |
| --seed0 | Random seed. |
Our run
Data table
| Store and rows | Device bytes ÷ user bytes |
|---|---|
| — | 4.91 |
| — | 56.9 |
| — | 5 |
| — | 1.8 |
| — | 2.02 |
Write amplification
| Store and rows | Write amplification |
|---|---|
| RocksDB (LSM), small rows | 4.91× |
| LMDB (B-tree), small rows | 56.9× |
| RocksDB (LSM), 8 KB rows | 5× |
| LMDB (B-tree), 8 KB rows | 1.8× |
| RocksDB + blob files, 8 KB rows | 2.02× |
RocksDB (LSM), small rows write amplification 4.9x
LMDB (B-tree), small rows write amplification 56.9x
RocksDB (LSM), 8 KB rows write amplification 5.0x
LMDB (B-tree), 8 KB rows write amplification 1.8x
RocksDB + blob files, 8 KB rows write amplification 2.0xWhat we found
For small rows the LSM won by an order of magnitude: RocksDB wrote 4.91× the user bytes and LMDB 56.9×. For 8 KB rows it lost: RocksDB stayed at 5× while LMDB fell to 1.8×. Blob files brought RocksDB's large-row amplification down to 2.02×, because compaction now moves keys and pointers, not values.
The LSM figure is well below the hand-computed 30, because this data set is small: with a few levels instead of three full ones, each row is rewritten fewer times. The direction of every comparison matched the expectation; the magnitude of the LSM's cost depends on how many levels the data fills.
Read it
Neither structure has "the" lower write amplification. The B-tree's cost is a ratio of sizes (page over row), so it is ruinous for small rows and nearly free for page-sized ones. The LSM's cost is a count of copies (levels times fan-out), so it is the same for any row size. That is why an LSM is the default for small-row write-heavy stores, and why the large-value LSMs separate keys from values: the copying is fine for keys and wasteful for payloads.
The number to compare is device bytes over user bytes, measured. Both models above are first-order: free space in pages, WAL, and compaction's real level sizes all move the result.