EVM Storage Layout: Why Slot Packing Matters for Both Gas and Security
EVM storage uses 32-byte slots. Slot packing saves gas. Storage collisions break upgradeable contracts. The mental model every Solidity developer needs.
TL;DR
- EVM contract storage is divided into 32-byte (256-bit) slots indexed by uint256. Each slot is a key-value entry in a flat keyed store:
slot_index → 32 bytes. - Solidity packs contiguous variables into the same slot if their sizes fit.
uint112 a; uint112 b; uint32 c;(224 + 32 = 256 bits) all live in one slot, costing one SSTORE to update all three together. - Uniswap V2 uses this aggressively:
reserve0 (uint112) + reserve1 (uint112) + blockTimestampLast (uint32)pack into one 256-bit slot, saving ~2,100 gas per swap. - Mappings and dynamic arrays use keccak256-based indexing, not sequential. Their storage layout has security implications, especially for upgradeable contracts.
- Most upgradeable-contract bugs are storage collisions: a new version of the contract uses a slot for a different variable than the old version, corrupting state.
Why this matters
Smart contract storage is the most expensive operation in the EVM. SSTORE costs 20,000 gas for a first-time write, 5,000 gas for an update, and even reading (SLOAD) costs 2,100 gas (cold) or 100 gas (warm) per access. Across millions of transactions per day, storage decisions are real money.
But the more dangerous side is security. Solidity assigns storage slots based on declaration order. If you change the order, you change the slot mapping. For non-upgradeable contracts, this is a deploy-time concern. For upgradeable contracts (proxy patterns), it's a runtime catastrophe waiting to happen.
If you're a Solidity developer, you need a mental model for how storage actually works. This article is that model.
The EVM storage model
EVM storage is a flat key-value store. Each contract has its own storage map. The keys are 256-bit integers (slot indices). The values are 256-bit integers (slot contents).
Three opcodes operate on storage:
- SSTORE: write a 32-byte value to a slot. Cost: 20,000 gas (first write to a slot, EIP-2929 + base) or 5,000 gas (update of existing non-zero value).
- SLOAD: read a 32-byte value from a slot. Cost: 2,100 gas (cold first access in tx) or 100 gas (warm subsequent access).
- TSTORE/TLOAD (Cancun, EIP-1153): the same operations on transient storage, which is auto-cleared at end of transaction. Costs ~100 gas.
The slot index is just a 256-bit number. Slot 0, slot 1, slot 0x1234567... are all valid. There's no upper bound; the address space is the entire uint256 range.
How does Solidity decide which variables live at which slot indices? Two rules:
Rule 1: contiguous storage variables get sequential slots starting at 0
contract Example {
uint256 a; // slot 0
uint256 b; // slot 1
address c; // slot 2 (upper 12 bytes wasted; address is only 20 bytes)
uint256 d; // slot 3
}
Each uint256 takes one full slot. An address takes only 20 bytes but consumes a full slot when surrounded by full-slot types.
Rule 2: contiguous variables that fit pack into one slot
contract Packed {
uint112 a; // slot 0, bytes 0-13
uint112 b; // slot 0, bytes 14-27
uint32 c; // slot 0, bytes 28-31
uint256 d; // slot 1
}
a + b + c = 256 bits = 32 bytes. They share slot 0. Variable d starts at slot 1.
Packing is ORDER-DEPENDENT. If you write:
contract NotPacked {
uint112 a; // slot 0
uint256 d; // slot 1 (a's slot is full because next var is uint256)
uint112 b; // slot 2
uint32 c; // slot 2
}
a doesn't pack with b, c because they're not contiguous in declaration order. The compiler doesn't reorder for you. Result: 3 slots used instead of 2.
Rule 3: structs, arrays, mappings have specific layouts
- Static array
T[N]: occupies N * sizeof(T) slots, in slots starting at the array's declared position. - Dynamic array
T[]: the array's "length" goes in the declared slot. The array's elements live starting atkeccak256(slot). So the elements are far away from the length, in a hash-derived region. - Mapping
mapping(K => V): has no length. The value for keyklives atkeccak256(k . slot), where.is concatenation.
The keccak256-based addressing for mappings and dynamic arrays is what makes them collision-resistant. Two different mappings have different "base slots", so their entries land in different regions of the storage space.
Walkthrough: Uniswap V2's reserve packing
Uniswap V2's pair contract has these storage variables:
contract UniswapV2Pair {
uint112 private reserve0; // slot 8, bytes 0-13
uint112 private reserve1; // slot 8, bytes 14-27
uint32 private blockTimestampLast; // slot 8, bytes 28-31
uint public price0CumulativeLast; // slot 9
uint public price1CumulativeLast; // slot 10
uint public kLast; // slot 11
}
The first three pack into slot 8. Each swap updates all three together in a single SSTORE.
The savings:
- Without packing: 3 SSTORE operations per swap = 60,000 gas (worst case) or 15,000 gas (warm updates) for the three writes.
- With packing: 1 SSTORE per swap = 20,000 gas (first write) or 5,000 gas (warm update).
Net savings per swap: roughly 10,000-40,000 gas depending on warm/cold state. At a typical 30 gwei gas price, that's $0.20-$0.80 per swap. Across Uniswap V2's lifetime volume of trillions of dollars, this is hundreds of millions of dollars in saved fees.
The cost of packing: the uint112 width. Reserves can't exceed 2^112 - 1 ≈ 5.19 × 10^33. For most tokens (typical 18-decimal supply of 10^9 to 10^12), this is enormous headroom. We covered this in the storage packing article.
When packing hurts
Packing isn't always a win. Two cases where it backfires:
Case 1: random access patterns
If you frequently access ONE field but not the others, packing forces you to read the whole slot anyway. Consider:
struct UserInfo {
uint128 deposit;
uint128 lastClaim;
}
mapping(address => UserInfo) public users;
function claimRewards() external {
// Only need lastClaim, but the SLOAD costs the same as if you were reading both.
}
This is fine in practice (a single SLOAD is cheap relative to the rest of the function), but if you had a workflow where most reads only need ONE field, separating them into two slots could enable lazy loading. Rare in practice; mostly a theoretical concern.
Case 2: independent updates
If you update one field frequently and another rarely, packing can hurt. Each update of either field is an SSTORE on the shared slot. The "cheap update" cost (5,000 gas) doesn't apply across separate variables; it applies across separate transactions on the same slot.
In practice, this is also rare. Solidity's defaulting to packing where possible is the right default for most cases.
Security: proxy storage collisions
The dangerous part of EVM storage is upgradeable contracts. The standard pattern: a Proxy contract delegatecalls to an Implementation contract. The Proxy's storage is the SOURCE OF TRUTH; the Implementation provides logic.
The trap: if you upgrade Implementation V1 to V2, and V2 has different storage layout than V1, the slots don't align.
Concrete example:
// V1
contract MyContract {
address public owner; // slot 0
uint public balance; // slot 1
}
// V2 (intended upgrade)
contract MyContract {
uint public version; // slot 0 ← BUG: this overwrites owner
address public owner; // slot 1 ← BUG: this overwrites balance
uint public balance; // slot 2
}
After upgrade, the Proxy's slot 0 (which used to hold the owner address) now holds whatever V2 reads as "version". Slot 1 holds garbage. The contract is corrupted.
The fix: never reorder existing variables. Add new variables at the END.
// V2 (correct)
contract MyContract {
address public owner; // slot 0 (unchanged)
uint public balance; // slot 1 (unchanged)
uint public version; // slot 2 (new, appended)
}
OpenZeppelin's upgrade plugins enforce this pattern by detecting layout changes and refusing to deploy if they would cause collision.
EIP-1967 standard slots
Proxy contracts often store the implementation address in a specific slot. To avoid collision with the implementation's variables, the standard is EIP-1967: use slots derived from keccak256(string) to put proxy metadata in unreachable slot indices.
bytes32 internal constant _IMPLEMENTATION_SLOT =
bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1);
The slot index is somewhere in the upper region of uint256 space. Solidity's automatic slot assignment for normal variables starts at 0 and increments; the chance of colliding with EIP-1967 slots is effectively zero.
This pattern (use keccak-derived slots for protocol metadata) is the canonical defense against storage collisions.
Related questions
Why is the slot index a 256-bit integer when we'll never use most of it? EVM word size is 256 bits. Using uint256 throughout makes the math uniform. The address space being huge means hash-derived slots (keccak256-based) effectively never collide with sequential slots.
Can two contracts share storage? Not directly. Each contract has its own storage map. delegatecall is the exception: it executes the called contract's code in the caller's storage context, allowing storage sharing through that mechanism.
What's the difference between storage and memory? Storage persists between transactions and is per-contract. Memory is per-execution and discarded after the transaction. Storage is expensive (~2-20K gas per access). Memory is cheap (~3 gas per byte). Use memory for temporary computation; only commit to storage what needs to persist.
Does the order of variables in a struct match the order in storage? Yes, with packing rules applied within each struct. A struct's fields pack into as few slots as possible per the contiguous-fit rule.
What about constants and immutables? Constants are inlined at compile time; they consume no storage. Immutables are encoded in the contract bytecode at deployment; they consume no storage but can't be changed. Both are gas-free to "read" relative to SLOAD.
How can I view a deployed contract's storage layout? forge inspect <contract> storageLayout (Foundry). Solidity's --storage-layout flag in solc. Both produce a JSON layout describing each variable's slot and offset.
Where to see this in Academy
The Uniswap V2 reconstruction implements pair-state (section 03 of the module) where the reserve+timestamp packing is explicit. The test suite verifies the packing works correctly, and you can see the gas savings vs unpacked alternatives.
We covered specific cases of storage packing in the storage packing article and the dual arithmetic article. Each shows storage decisions in production code with the rationale behind them.
The eMBA Module 3 covers proxy patterns and upgradeability briefly; for a deeper dive into upgrade safety, the OpenZeppelin documentation is the canonical reference. Their upgrade plugins are the production tooling for avoiding storage collisions.
When you rebuild Uniswap V2 yourself in Zealynx Academy, you implement the packed _update() function from scratch and watch the test suite reject implementations that don't pack correctly. That makes the storage cost-savings viscerally clear in a way that reading about it doesn't.
Tagged