Part II · Chapter 6 of 43

Flat DB — reading state in one hop

The protocol says state is a trie. It never says how to lay that trie on a disk and read it billions of times — and that's exactly where clients quietly compete. Nethermind's Flat DB, by Muhammad Amirul Ashraf, is a ground-up answer.

Updated Jun 22, 2026 · 13 min
Assumed
  • how Ethereum stores state

The protocol’s view of state is clean and final: every account folds into a Merkle-Patricia trie, committed to a single 32-byte stateRoot. But that’s a logical picture. A real client has to take that trie and put it on a physical SSD, inside an embedded key-value database (RocksDB), and then read from it — thousands of accounts and storage slots per block, billions of times over a sync. How you arrange those bytes on disk is invisible to the protocol and decides whether a block executes in 200 milliseconds or two seconds. It’s one of the few places clients genuinely compete, and the spec says nothing about it.

This is a deeper, implementation-level explorable: the same build-from-the-flaw approach, applied to a piece of real, recent engineering. In 2024–2025, Nethermind engineer Muhammad Amirul Ashraf shipped Flat DB, a ground-up rethink of how the .NET client stores state. Let’s derive it the way it was discovered — one bottleneck at a time.

1 step The read tax

To read one balance, you walk the whole trie

Store the trie the obvious way: each node in a key-value table, keyed by its hash. To read Alice’s balance you start at the stateRoot, look up that node, find the child hash for the first nibble of her key, look that up, and so on — 4 to 6 separate lookups, each a potential disk read, just to reach one leaf. And because keys are hashes, consecutive accounts scatter randomly across the disk, so there’s no locality to exploit; worse, trie nodes are bulky — a branch lists sixteen child hashes — so you haul tens of times more bytes than the value you wanted.

read 0xA7… balance root node node node value one account = 4 to 6 random disk reads down the trie
Hash-keyed storage: one account read becomes 4–6 scattered disk reads, hopping node to node down the trie. The traversal — not the data — is the cost.

Two separate things hurt here, and it’s worth pulling them apart. First, depth: every read is several dependent hops down the tree. Second, locality: because the key of each node is its hash — effectively a random number — the node you need next sits at a random place on disk, so each hop is a fresh cache miss and a real seek. Fix the cheaper one first.

→ Step 2: key nodes by where they live in the tree, not by their hash.

2 step HalfPath

Key nodes by their path, so neighbours sit together

This was Nethermind’s previous design, HalfPath, and it’s a clean half-step toward Flat DB. Keep the trie exactly as it is, but change the database key of each node: instead of its hash, prefix the key with the trie path to that node. Now a node’s key encodes where it lives in the tree, so RocksDB — which stores entries sorted by key — physically places parent and child near each other. Walking a path becomes a short, sequential sweep instead of a scatter of random seeks.

hash-keyed: random disk slots six hops, each to a random slot, a cache miss every time path-keyed: neighbours sit together one sweep over neighbouring slots, ~50% faster, ~25% smaller
Same trie, different key. Hash keys scatter a path's nodes to random disk slots (a cache miss per hop); path keys cluster them, so one read sweeps neighbouring slots — and the more orderly bytes compress better too.

That layout change alone is a big win: roughly 40–50% faster block processing, and a database about 25% smaller, because path-ordered data is far more compressible (in one 12-day test the uncompressed DB grew 1.28% instead of the usual 14.62%). The “Half” in the name is the honest part — it’s a hybrid: it reorganizes the keys but keeps the rest of the trie machinery, a middle ground between the old hash-keyed store and a pure path-based one.

→ Step 3: read the value directly, and keep the trie off the hot path.

3 step Flat DB

Store the values flat; keep the trie only for the root

Here’s the move. Split the two jobs the trie was doing. Proving the state — recomputing the stateRoot — genuinely needs the tree. Reading a value does not. So keep a dedicated flat column of address → account and another of (address, slot) → value, and answer every read from those in a single direct lookup. The trie is still maintained, but off to the side, consulted only when you need to recompute the root at the end of a block. That’s Flat DB: account data, storage data, and trie nodes split into separate RocksDB columns, with the flat columns — not the trie — as the primary read path.

read 0xA7… balance ✗ 6 disk hops✓ 1 lookup flat account column 0x00… → … 0xA7… → 12 Ξ 0x22… → … 0x33… → … trie root kept only to recompute the stateRoot at commit
Flat DB answers a read from a flat key-value column in one lookup. The trie is kept aside, used only to recompute the stateRoot at commit time.

A read collapses from 4–6 dependent lookups to one. In Nethermind’s tests that’s about 20% higher execution throughput over HalfPath, and around 40% on read-heavy blocks where state access dominates. It also moves less data, since a flat account entry is far smaller than the trie nodes you’d have walked to reach it.

→ Step 4: give the flat store a memory.

4 step Remembering the past

First: a layer is a diff, not a copy

This is the bit that trips everyone, so let’s nail it before anything else. A layer is not a full flat DB for that block. It’s a diff — only the handful of accounts and storage slots that that one block changed, mapped to their new values. At the very bottom sits the base flat DB, the complete state as of some older point; every layer above it is a thin patch. To read a key you check the newest layer, then the next, falling down until you hit it — the first (newest) match wins — and if no layer mentions it, you land on the full base.

read key B B block N · diffA=9 C=2 block N-1 · diffB=5 ✓ block N-2 · diffA=7D=1 shadowed base flat DB · the full state each layer holds only the keys its block changed; a read takes the newest match
Each layer holds only the keys its block touched. A read falls from the newest layer down and takes the first match — so block N's A=9 shadows the stale A=7 below it. Miss every layer and you read the full base.

So one block’s layer is tiny (a block writes a few thousand keys, not millions). The only problem is their number: one diff per block means thousands of layers, and a read would crawl through the whole pile. That’s the thing compaction fixes — and now you can see exactly what “merge” means.

Merging two layers = keep the newest value of each key

Fusing block 5’s diff with block 6’s diff is just what you’d guess: take the union of their keys, and wherever both touched the same key, keep the newer value and discard the older. That’s the answer to why it gets smaller — a key that was rewritten across the range collapses from several entries down to one. Hot keys (a busy contract’s storage, an exchange’s balance) change almost every block, so the overlap, and the savings, are large.

block 5 · diffA=7 B=5 block 6 · diffA=9 C=2 compacted 5-6 · diff A=9 B=5 C=2 older A=7 dropped keep the newest value of each key; duplicates collapse, so it shrinks intra-range reads are lost
Merge = union the two diffs, keep the newest value per key. A was written in both blocks, so the older A=7 is dropped and only A=9 survives — three entries instead of four. The merged layer is smaller than the two it replaced.

And that also answers “how do you tell the merged blocks apart inside a compacted layer?” — you don’t, and you don’t need to. A compacted “blocks 5–8” layer keeps only each key’s final value as of block 8; block 6’s intermediate value is gone for good. You’ve given up the ability to read a state from inside the range. That’s safe for one precise reason: single-block layers are only kept for the recent, reorg-able blocks — the ones you might have to roll back. Older blocks are settled, so no one will ever ask to read “as of block 6” specifically, and collapsing them costs you nothing you’ll miss.

Which runs to merge: powers of two

That leaves one question — which runs of layers to fuse. The clever answer is powers of two: keep merging so that at any moment you hold at most one layer of each size — one 1-block, one 2-block, one 4-block, one 8-block, and so on.

8 4 2 1 blocks 1-8 1-45-8 1-23-45-67-8 b1b2b3b4b5b6b7b8 each round halves the layers: 128 blocks need only ~7
Compaction fuses layers in pairs — 8 single-block layers become 4, then 2, then 1. Each round halves the count, so the number of layers grows like the number of bits, not the number of blocks.

The precise rule is “a new compacted layer’s span is the largest power of two that divides the block number.” Roll it forward and a pattern falls out: blocks 1 and 2 fuse into a 2-layer; block 3 sits alone until block 4 fuses everything into a 4-layer; and so on. The set of layers you hold is therefore exactly the binary digits of the current block number. After block 13 — binary 1101 — you hold an 8-layer, a 4-layer, and a 1-layer: just three layers covering thirteen blocks.

→ Step 5: find the read you forgot you were still doing.

5 step Why it needs the trie

You still touch the trie — to commit

Flat reads are fast, but a block isn’t only reads. At the end of every block the client must recompute the new stateRoot, and that still means reading and rehashing trie nodes along every changed path. Ashraf is blunt about it: “Without these optimizations, Flat DB is not significantly faster than HalfPath.” The flat read path removed one bottleneck and exposed the next.

The fix is two pieces of plumbing around that commit-time trie access. A TrieNodeCache — a sharded hash table indexed by path and hash — keeps the hot trie nodes in memory. And a TrieNodeWarmer prefetches the trie nodes a block is about to touch, so they’re already in memory when the root computation needs them, hiding the disk latency. With those in place, the flat read win actually shows up end-to-end.

03 Spec & Code The layouts, side by side

One idea, four shapes

Flat DB isn’t a single setting — it’s a family of layouts trading speed against memory, because different operators want different points on that line:

LayoutRead pathTuned forCost
HalfPathwalk the path-keyed triethe shipping default~200 GB, slower reads
Flatdirect flat lookuplowest latency~260 GB, ~32 GB RAM
FlatInTrieflat index inside the trieconstrained memoryfew-MB index, noticeably slower
PreimageFlatflat with raw (un-hashed) keysexperimentation onlycan’t sync or import state

The throughline is the same one from the whole derivation: the protocol fixes the root, never the storage. Every row here computes the identical stateRoot — they just disagree about how much disk, memory, and risk to spend getting reads to go faster.

HalfPath and Flat DB weren’t the only directions Nethermind explored, either. Alongside them sat Path-Based storage (replace hash identifiers with trie paths entirely at the RocksDB level — no pruning needed, and a natural fit for serving snap sync) and Paprika (a custom Patricia-tree engine built from scratch around path-based access, with merkleization as a pluggable component and finality driving when the database flushes). Same goal, three different bets on how far to rebuild the storage layer.

04 Go Deeper Where to take it from here