Skip to content
About
Main content

LSM versus B-tree write amplification, derived and measured

Run a real systemNeeds Docker

Problem

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: about page size ÷ row size. An LSM tree never updates in place; it writes rows into sorted files and rewrites them as 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 () 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

For 8 KB rows, which store writes fewer device bytes per user byte?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    lsm-versus-b-tree-write-amplification.py63 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Write amplification by store and row sizeApple M2 · 8 cores · 8 GB
Data table
Write amplification by store and row size
Store and rowsDevice bytes ÷ user bytes
—4.91
—56.9
—5
—1.8
—2.02
Linux 6.10.14-linuxkit in Docker (macOS 26.6.2 host) · Python 3.12.12 · rocksdict 0.3.29 · lmdb 3.0.0 · run 2026-10-20

Write amplification

Write amplification
Store and rowsWrite amplification
RocksDB (LSM), small rows4.91×
LMDB (B-tree), small rows56.9×
RocksDB (LSM), 8 KB rows5×
LMDB (B-tree), 8 KB rows1.8×
RocksDB + blob files, 8 KB rows2.02×
Output · uv run lsm-versus-b-tree-write-amplification.py
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.0x

What 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, , and compaction's real level sizes all move the result.