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.
- 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
| Layout | Read path | Tuned for | Cost |
|---|---|---|---|
| HalfPath | walk the path-keyed trie | the shipping default | ~200 GB, slower reads |
| Flat | direct flat lookup | lowest latency | ~260 GB, ~32 GB RAM |
| FlatInTrie | flat index inside the trie | constrained memory | few-MB index, noticeably slower |
| PreimageFlat | flat with raw (un-hashed) keys | experimentation only | can’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.