Write Amplification

What it is

Write amplification (WA) is the ratio of bytes physically written to storage to bytes the application logically asked to write:

write amplification = bytes written to the device / bytes written by the user

A WA of 10 means each 1 MB of user data costs 10 MB of disk writes. It matters for three reasons:

  1. Throughput: disk write bandwidth is finite. At 10x WA, a device that sustains 1 GB/s of writes supports only 100 MB/s of user writes.
  2. Device lifetime: SSD flash cells survive a limited number of erase cycles; drives are rated in total bytes written (TBW) or drive-writes-per-day. 10x WA wears the drive 10x faster. See Storage Hardware.
  3. Latency: background rewriting competes with foreground reads and writes for the same device.

It always comes paired with its siblings — read amplification (reads per lookup) and space amplification (disk bytes per live byte) — and reducing one usually raises another. See Storage Engines.

Where it comes from: every layer multiplies

graph TD user["User writes 1 MB"] --> wal["WAL: ~1x<br/>(every byte logged once)"] user --> engine["Engine data structure:<br/>B+Tree pages or LSM compaction"] engine --> fs["File system: journal, metadata"] fs --> ssd["SSD FTL garbage collection"] ssd --> total["Total = product of the layers"]

The factors multiply: an engine at 10x running on an SSD with 2x internal amplification produces 20x at the flash.

B+Tree engines

Source Why
Page granularity Changing one 100-byte row dirties a whole 8 KB page. If that page is written back before another change hits it, WA for that update ≈ 80x
Every index One row insert modifies the heap page plus one page per index
WAL Every change is written once to the log, then again to the page
Torn-page protection PostgreSQL full-page images after each checkpoint; InnoDB doublewrite buffer — page content written twice
Splits A split writes two pages plus the parent
graph LR w["1 logical INSERT"] --> heap["heap page write"] w --> i1["index 1 page"] w --> i2["index 2 page"] w --> i3["index 3 page"] w --> walr["WAL record"]

The saving grace is write combining: if a hot page receives 100 updates between checkpoints, it is written back once, so its per-update cost falls by 100x. B+Tree WA is therefore worst for random updates over a dataset much larger than memory — every update dirties a different cold page.

LSM engines

Each byte is written to the WAL, written again when the memtable is flushed, and then rewritten every time compaction moves it to a deeper level.

Worked example (leveled compaction):

  • Size ratio between levels: 10.
  • Levels: L0, L1 ... L4.
  • Moving data from Ln to Ln+1 merges it with roughly 10x as much overlapping data in Ln+1, and rewrites all of it. Per byte entering a level, about 10 bytes of the next level are rewritten — though in practice it is often closer to half that, since overlaps are partial.
WAL                        1
Flush to L0                1
L0 → L1                   ~1–2
L1 → L2, L2 → L3, L3 → L4 ~5–10 each   → ~15–30
---------------------------------------
Total                     ~18–35x

That matches the 10–30x commonly observed for RocksDB's default leveled compaction. Tiered compaction rewrites each byte only about once per tier, giving a much lower WA (often 2–10x) at the cost of more overlapping files to read and more temporary space.

Despite a higher ratio than a B+Tree might show for a hot working set, LSM writes are large and sequential, which is cheap for both HDDs (no seeks) and SSDs (whole erase blocks freed together, little FTL garbage collection). So "higher WA" does not automatically mean "slower."

Copy-on-write B+Trees

LMDB, BoltDB, and Btrfs never modify a page in place: updating a leaf writes a new leaf, a new parent pointing at it, and so on up to a new root. One small update writes height pages — ~3–4 pages, so tens of KB for a 100-byte change — unless many updates are batched into one transaction, which shares the rewritten upper levels.

The SSD layer

Inside the SSD, the FTL relocates still-valid pages before erasing a block. Device-level WA depends on how full the drive is and how the data dies: random small overwrites on a nearly full drive can push it above 3–5x; large files written and deleted whole stay close to 1x. TRIM (discard) and keeping free space on the drive keep it low.

Measuring it

# Bytes written by a process (Linux): write_bytes counts bytes sent to the block layer
cat /proc/$(pgrep -f db_bench)/io

# Device-level: watch wkB/s while the benchmark runs
iostat -x 1

# SSD lifetime counters (vendor-specific fields)
sudo smartctl -a /dev/nvme0n1 | grep -i "data units written"

Compare device bytes written against the bytes your benchmark inserted. RocksDB also reports it directly in its compaction statistics (the W-Amp column in rocksdb.stats).

Mitigation

Technique Applies to Effect
Fewer indexes B+Tree Each removed index removes one page write per row change
Batch writes / larger transactions All More changes per page written; fewer WAL syncs
Larger memtable LSM Fewer, larger L0 files; more overwrites absorbed in memory
Tiered / universal compaction LSM Lower WA, higher read and space amplification
Key-value separation (WiscKey, BlobDB) LSM Large values written once to a value log; compaction moves only keys
Compression All Fewer physical bytes per logical byte
Free space + TRIM SSD Lower FTL garbage-collection cost

Common pitfall

Adding indexes freely because "reads need to be fast," without weighing the write side, is how write amplification quietly creeps up on a write-heavy table — every additional index is another mandatory page write on every insert, update, and delete, whether or not that index is ever used by a query. See Index Types for when an index's write cost outweighs its read benefit. PostgreSQL's pg_stat_user_indexes.idx_scan = 0 finds indexes that are paying the write cost without ever serving a read.