Skip to content

Storage Slot Layout

The previous page took a contract apart from the outside: the ABI and 4-byte selectors are how a caller names a function and hands it arguments. This page goes inside, to the one thing a contract keeps between calls — its storage — and asks a deceptively simple question: when you write uint256 total; in Solidity, where does total actually live, and what does touching it cost?

That question matters because storage is the only part of a contract that persists. The world state commits to it under each contract’s storageRoot; a call that changes it moves the whole chain’s stateRoot. Storage is also, by a wide margin, the most expensive resource the EVM sells. So “where does this variable live” is really two questions — how does Solidity decide the address and how much gas does agreeing on that write cost the world computer — and this page answers both.

Forget variables for a moment. At the raw EVM level, a contract’s storage is not a struct, an array, or a heap. It is a single, flat map:

256-bit key ─────────► 256-bit value
slot 0x0000...0000 ─► 0x0000...002a
slot 0x0000...0001 ─► 0x0000...0000
slot 0x9f3c...be08 ─► 0x0000...0001
... ...

Both the key and the value are exactly 256 bits (32 bytes). The keyspace is the full $2^{256}$ — astronomically larger than any contract could ever fill — and every slot that has never been written reads as zero. There is no allocation and no “size”: storage is a total function from every 256-bit key to a 256-bit value, almost all of it zero.

The mini-EVM in the companion ethmini crate models this exactly, only with a narrower word for readability:

// rust/ethmini/src/vm.rs — the two opcodes that ARE storage
/// Pop `key`, then pop `value`; set `storage[key] = value`. Persistent: it
/// outlives the call and changes the account's state (and the state root).
SStore,
/// Pop `key`; push `storage[key]` (0 if unset).
SLoad,

That is the entire storage interface the EVM exposes: SLOAD (read a slot) and SSTORE (write a slot). Two opcodes, one 256-bit key, one 256-bit value. Everything Solidity does — every struct, mapping, and dynamic array you will ever write — is a layout convention built on top of these two primitives. Solidity’s job is to decide, deterministically, which slot number each of your state variables uses, so that the compiled bytecode and the next compile of the same source agree.

For the “plain” state variables — the ones whose size is known at compile time and does not grow — Solidity assigns slots sequentially, in declaration order, starting at slot 0.

contract Ledger {
uint256 total; // slot 0
address owner; // slot 1
uint256 count; // slot 2
}

total is slot 0, owner is slot 1, count is slot 2. There is no header, no metadata — the first state variable simply is slot 0. This is why declaration order is a permanent, ABI-adjacent commitment: reorder these three lines and you have silently reassigned every variable to a different slot, which is a notorious footgun when upgrading a contract that shares storage with a new implementation.

declaration order storage
───────────────── ───────
uint256 total ───────► slot 0
address owner ───────► slot 1
uint256 count ───────► slot 2

The mapping is purely a compiler convention. Nothing on-chain records “slot 1 is named owner”; the bytecode just emits SSTORE(1, ...) wherever your source writes owner. The names live only in the source and the (optional) ABI — which is the first hint of the secrecy point we return to at the end.

A slot is 32 bytes. Most variables do not need all 32. An address is 20 bytes; a bool is 1 byte; a uint128 is 16 bytes. Wasting a full 32-byte slot on a 1-byte bool would be extravagant, because — as we will price out below — every slot you touch costs gas. So Solidity packs consecutive small variables into the same slot when they fit:

contract Packed {
uint128 a; // slot 0, bytes 0..16
uint128 b; // slot 0, bytes 16..32 ← shares slot 0 with a
address owner; // slot 1, bytes 0..20
bool paused; // slot 1, byte 20 ← shares slot 1 with owner
uint256 big; // slot 2 (needs a full slot of its own)
}
slot 0: [ a: 16 bytes ][ b: 16 bytes ] (fully packed)
slot 1: [ owner: 20 bytes ][ paused: 1 ][ unused: 11 ] (partially packed)
slot 2: [ big: 32 bytes ] (its own slot)

The packing rules are mechanical and worth internalizing:

  • Variables are laid out in declaration order, filling the current slot left to right.
  • If the next variable fits in the bytes remaining in the current slot, it goes there.
  • If it does not fit, it starts a fresh slot (the remaining bytes of the previous slot are left unused).
  • A uint256, bytes32, mapping, or dynamic array always starts a new slot and consumes the whole thing.

This is why the order of your declarations affects gas. uint128 a; uint256 big; uint128 b; uses three slots (a alone in 0, big in 1, b alone in 2), while uint128 a; uint128 b; uint256 big; uses two (a and b share 0, big takes 1). Same data, one fewer slot — and, as the next section shows, a slot is expensive enough that this reordering is a real optimization.

Packing is free to write only when the writes coincide, and it costs a little to read. The EVM has no “read bytes 16..32 of a slot” instruction — SLOAD always returns the full 256-bit word. To read b out of the packed slot 0, the compiled bytecode does:

x = SLOAD(0) // the whole 32-byte word
b = (x >> 128) & MASK128 // shift b down, mask off a

So packing trades a handful of cheap arithmetic/masking opcodes (a few gas each) for sharing an expensive slot. The trade is almost always worth it — but it explains why writing one field of a packed slot must first SLOAD the whole slot, modify the one field, and SSTORE it back, so you don’t clobber its neighbors. Packing saves storage slots; it does not make each individual write free.

Mappings and dynamic arrays: keccak256 addressing

Section titled “Mappings and dynamic arrays: keccak256 addressing”

Sequential slots work only when the compiler knows how many values there are and how big each is. A mapping(address => uint256) breaks both assumptions: it can hold an entry for any of $2^{160}$ possible addresses, and you cannot reserve $2^{160}$ consecutive slots. A dynamic array can grow to any length. So Solidity uses a different trick: it derives an entry’s slot address by hashing.

For a mapping declared at slot p, the value for key k lives at:

slot(mapping[k]) = keccak256(h(k) . uint256(p))

where . is byte concatenation and h(k) is the key padded to 32 bytes (for value-type keys) — so a mapping at slot 2 stores balances[alice] at keccak256(alice_padded_to_32 . 2). The declared slot p itself holds nothing; it is reserved so that p is not reused by the next variable, but the actual data is scattered across the keyspace at hash-derived addresses.

mapping balances declared at slot 2 → slot 2 holds NOTHING
balances[alice] ─► keccak256( alice . 2 ) ─► 0x9f3c... ─► value
balances[bob] ─► keccak256( bob . 2 ) ─► 0x2a71... ─► value

Dynamic arrays use the same idea, one level of indirection different. For a dynamic array arr at slot p:

  • slot p itself holds the length of the array;
  • element i lives at keccak256(p) + i — a contiguous run starting at the hash of the slot number.
uint256[] arr declared at slot 3
slot 3 ─► length (say, 4)
keccak256(3) + 0 ─► arr[0]
keccak256(3) + 1 ─► arr[1]
keccak256(3) + 2 ─► arr[2]
keccak256(3) + 3 ─► arr[3]

Why keccak256? Two reasons, and they are the same two that make the state trie a “secure trie”. First, the hash spreads entries uniformly and unpredictably across the $2^{256}$ keyspace, so a mapping’s entries and an array’s elements never collide with each other or with the low sequential slots — the odds of a collision are the odds of a keccak256 collision, i.e. none. Second, an attacker cannot choose where their entry lands, because they cannot invert the hash. The addressing is a pure function of (structure's slot, key/index), so it is fully deterministic — the same source compiles to the same layout every time, and any node re-executing the contract computes the identical slot.

Layout is an optimization surface, not just bookkeeping

Section titled “Layout is an optimization surface, not just bookkeeping”

Put the pieces together and a clear engineering picture emerges: storage layout is where gas is won or lost. Because a fresh slot costs ~20,000 gas and an update ~5,000, and because a SLOAD is 100× more expensive warm-vs-cold than a memory read, the two biggest storage optimizations both fall directly out of this page:

  • Packing — order your small state variables so they share slots, turning several SSTOREs into one. Two uint128s in one slot is one write; in two slots it is two.
  • Slot reuse — prefer updating an existing non-zero slot (~5,000) over writing a fresh one (~20,000), and be aware that clearing a slot back to zero earns a partial refund (which is why some contracts “clean up” storage they no longer need).

These are not micro-optimizations invented by clever developers; they are the direct, intended consequence of the gas schedule pricing the network’s cost of remembering things. Every fresh slot is a permanent obligation on every full node forever, so the EVM prices it like one.

One last, load-bearing consequence. Solidity has a private keyword:

contract Vault {
uint256 private secretNumber; // slot 0 — private, but NOT hidden
}

private means the Solidity compiler will refuse to let other contracts or derived contracts reference the name secretNumber in their source. That is a compile-time, access-control guarantee about code — nothing more.

It says nothing about secrecy on-chain. secretNumber still lives at slot 0, and every full node stores that slot in the clear. Anyone can read it directly, without any special access, via the eth_getStorageAt JSON-RPC call:

Terminal window
# Read slot 0 of a contract directly off-chain — no ABI, no permission needed.
cast storage 0xContractAddress 0
# or, over raw JSON-RPC:
# eth_getStorageAt(address, "0x0", "latest")

Because the storage layout is deterministic and public (slot 0, slot 1, keccak256(key . slot) for mappings), an outside observer can compute exactly which slot any variable — including a private one — occupies, and read it. This has burned real projects that stored passwords, seeds, or “hidden” configuration in private variables expecting them to be unreadable.

The first-principles reason is the whole thesis of the book: untrusting strangers can only agree on the state of the shared world computer if that state is fully visible to all of them. Every node must hold, and be able to check, every storage slot — otherwise it could not verify the stateRoot. Verifiability and secrecy are in direct tension; Ethereum chose verifiability. If you need on-chain secrecy, you must encrypt or commit to a hash before writing (and even then the ciphertext or commitment is public). private is an organizing tool for your code, never a vault.

  • Why does it exist? Because the EVM offers only a flat 256-bit key→256-bit value store (SLOAD/SSTORE), yet developers want structs, mappings, and arrays. Storage layout is the deterministic convention that maps rich Solidity types onto that flat store so both compiler and every re-executing node agree on where each value lives.
  • What problem does it solve? It makes variable placement deterministic and reproducible — sequential slots for fixed data, keccak256-derived addresses for unbounded structures — so the same source always compiles to the same layout and any node computes the identical slot for any value.
  • What are the trade-offs? Determinism and simplicity cost you two things: reordering declarations silently reassigns slots (an upgrade footgun), and the layout is fully public, so private gives no secrecy. Packing recovers gas but adds mask/shift work on reads.
  • When should I avoid relying on it? Never rely on it for secrecy — anything in storage is world-readable. And never hand-manage raw slots when Solidity’s layout suffices; you only reach for assembly/sstore at explicit slots (e.g. proxy patterns) when you deliberately need to dodge the default layout.
  • What breaks if I remove it? Without a deterministic layout, two compiles — or two nodes — could place owner in different slots, so they would disagree on the contract’s state and compute different storageRoots. The chain could no longer agree on the world; the whole state-machine consensus falls apart.
  1. At the raw EVM level, what exactly is a contract’s storage, and which two opcodes are the entire interface to it? What does an unwritten slot read as?
  2. Given uint256 total; address owner; uint256 count;, which slot does each occupy, and why is reordering these declarations a dangerous change for an upgradeable contract?
  3. Explain variable packing: state the rule for when two consecutive variables share a slot, and rewrite uint128 a; uint256 big; uint128 b; to use one fewer storage slot.
  4. A mapping(address => uint256) balances is declared at slot 2. Where does balances[alice] live, and what does slot 2 itself hold? Why is keccak256 the right tool for this addressing?
  5. A contract stores a lottery seed in a private uint256. An attacker with no special access reads it and wins every draw. Which specific assumption about private was wrong, and what is the first-principles reason storage cannot be secret on Ethereum?
Show answers
  1. Storage is a single flat map from 256-bit keys (slots) to 256-bit values — the full $2^{256}$ keyspace, almost all of it zero. The entire interface is two opcodes: SLOAD (read a slot) and SSTORE (write a slot). Any slot that has never been written reads as 0.
  2. total → slot 0, owner → slot 1, count → slot 2 (sequential, in declaration order from slot 0). Reordering is dangerous because slots are assigned by position, not name: a new implementation that declares them in a different order will read/write different slots against the same physical storage, silently corrupting state in a shared-storage upgrade.
  3. Two consecutive variables share a slot if the second fits in the bytes remaining in the current 32-byte slot; otherwise it starts a fresh slot. Reordered as uint128 a; uint128 b; uint256 big;, a and b pack into slot 0 (16 + 16 bytes) and big takes slot 1 — two slots instead of three.
  4. balances[alice] lives at keccak256(alice_padded_to_32 . uint256(2)). Slot 2 itself holds nothing — it is reserved but empty, since a mapping can’t fit its entries in one slot. keccak256 spreads entries uniformly and unpredictably across the huge keyspace (so entries never collide and can’t be steered by an attacker) while remaining a deterministic function of (key, slot), so every node computes the same address.
  5. The wrong assumption was that private hides data from outsiders. private is only a compile-time access-control rule about which code may reference the name — it has no effect on-chain. The seed still sits in a known slot that anyone can read with eth_getStorageAt. The first-principles reason: untrusting nodes can only agree on the world’s state (and verify the stateRoot) if every slot is fully visible to all of them, so verifiability rules out secrecy — for confidentiality you must encrypt or use commit–reveal.