EVM Storage Layout: How Solidity Stores Data On-Chain
Learn how the EVM storage layout works: storage slots, variable packing, mappings, dynamic arrays, structs, proxy collisions, and gas costs for SLOAD/SSTORE.
TL;DR: EVM storage is a 256-bit key-value store where each smart contract gets its own isolated storage space. Solidity assigns state variables to sequential 32-byte slots starting from slot 0, packs smaller variables together, derives mapping locations using keccak256(key . slot), and stores dynamic array elements starting at keccak256(slot). Understanding this layout is essential for gas optimization, proxy contract safety, and reading on-chain data directly. This guide covers every aspect of the storage model with concrete examples, gas costs after EIP-2929, and the proxy collision pitfalls that have caused real exploits.
The Storage Model: 256-Bit Key-Value Pairs
Every smart contract on Ethereum has access to a persistent storage space that survives between transactions. This storage is conceptually a mapping from 256-bit keys (called slots) to 256-bit values. The theoretical address space is 2^256 slots — far more than could ever be used. In practice, only slots that have been explicitly written to consume any resources.
EVM storage is the most expensive resource on Ethereum. Writing a new value to a previously empty slot costs 20,000 gas, while a simple addition costs just 3 gas. This 6,600x cost difference drives nearly every storage optimization technique in Solidity development.
At the EVM level, storage is accessed through two opcodes:
- SLOAD: Reads 32 bytes from a storage slot onto the stack
- SSTORE: Writes 32 bytes from the stack to a storage slot
# SLOAD example: read storage slot 0
# Bytecode: 600054
# 60 00 -> PUSH1 0x00 Stack: [0]
# 54 -> SLOAD Stack: [value_at_slot_0]
You can step through SLOAD and SSTORE execution in the Zig EVM Playground to see exactly how the stack and storage interact.
How Solidity Assigns Storage Slots
Sequential Slot Assignment
Solidity assigns state variables to storage slots in the order they are declared, starting from slot 0:
contract SimpleStorage {
uint256 public value; // Slot 0
address public owner; // Slot 1
uint256 public totalCount; // Slot 2
bool public isActive; // Slot 3
}
Each of these variables occupies one full 32-byte slot, even though address only needs 20 bytes and bool only needs 1 byte. This is wasteful, which is why variable packing exists.
Variable Packing Rules
When consecutive state variables fit within a single 32-byte slot, Solidity packs them together. The rules are:
- Variables are packed sequentially in declaration order
- Each variable is right-aligned within its allocation
- If the next variable does not fit in the remaining space of the current slot, it starts a new slot
- Structs and arrays always start a new slot
- Members within a struct follow the same packing rules
contract PackedStorage {
// Slot 0: 1 + 20 + 1 = 22 bytes -- all fit in one slot
uint8 public flag; // Slot 0, byte 0
address public owner; // Slot 0, bytes 1-20
bool public isActive; // Slot 0, byte 21
// Slot 1: 32 bytes -- fills entire slot
uint256 public value; // Slot 1
// Slot 2: 16 + 16 = 32 bytes -- exactly one slot
uint128 public balance1; // Slot 2, bytes 0-15
uint128 public balance2; // Slot 2, bytes 16-31
}
Optimized declaration order can save significant gas. Consider the difference:
// BAD: 3 slots used
contract Wasteful {
uint8 a; // Slot 0 (31 bytes wasted)
uint256 b; // Slot 1 (cannot fit with a)
uint8 c; // Slot 2 (31 bytes wasted)
}
// GOOD: 2 slots used
contract Efficient {
uint8 a; // Slot 0, byte 0
uint8 c; // Slot 0, byte 1 (packed with a)
uint256 b; // Slot 1
}
The efficient version saves one SSTORE (20,000 gas) on deployment and one SLOAD (2,100 gas) whenever both a and c are read in the same transaction.
Mappings: Hash-Based Slot Derivation
Mappings are the most widely used data structure in Solidity, and their storage layout is elegantly simple. A mapping declared at slot p does not store any data at slot p itself. Instead, the value for key k is stored at:
slot = keccak256(abi.encode(k, p))
The key and the mapping’s slot number are concatenated and hashed. Because keccak256 produces uniformly distributed outputs across the 2^256 space, the probability of two different mappings producing the same slot is negligible.
contract MappingExample {
uint256 public x; // Slot 0
mapping(address => uint256) public balances; // Slot 1 (empty)
mapping(uint256 => mapping(address => bool)) public approvals; // Slot 2
// balances[addr] is at: keccak256(abi.encode(addr, 1))
// approvals[id][addr] is at: keccak256(abi.encode(addr, keccak256(abi.encode(id, 2))))
}
For nested mappings, the hashing is applied recursively. The outer mapping produces an intermediate slot, and the inner mapping hashes the inner key with that intermediate slot.
Reading Mapping Values with eth_getStorageAt
You can read any mapping value directly from an Ethereum node without calling the contract:
// JavaScript: Read balances[0xABC...] from slot 1
const slot = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["address", "uint256"],
["0xABC...", 1] // key, mapping slot
)
);
const value = await provider.getStorage(contractAddress, slot);
This technique is essential for off-chain indexers, block explorers, and forensic analysis.
Dynamic Arrays: Length Plus Hashed Data
Dynamic arrays store their length at the declared slot and their elements starting at a derived location:
- Length: Stored at slot
p(the array’s declared slot) - Element i: Stored at slot
keccak256(p) + i
contract ArrayLayout {
uint256 public x; // Slot 0
uint256[] public values; // Slot 1 holds array length
// values[0] is at keccak256(abi.encode(1)) + 0
// values[1] is at keccak256(abi.encode(1)) + 1
// values[n] is at keccak256(abi.encode(1)) + n
}
For arrays of types smaller than 32 bytes, elements are packed just like state variables:
contract PackedArray {
uint128[] public items; // Slot 0 holds length
// items[0] and items[1] share slot keccak256(0) + 0
// items[2] and items[3] share slot keccak256(0) + 1
}
Bytes and String
bytes and string types use a special encoding:
- Short values (31 bytes or fewer): The data and its length are stored together in the declared slot. The lowest-order byte stores
length * 2. - Long values (32 bytes or more): The declared slot stores
length * 2 + 1, and the data is stored starting atkeccak256(p), spanning as many slots as needed.
contract StringLayout {
string public name; // Slot 0
// If name = "hello" (5 bytes):
// Slot 0 = 0x68656c6c6f00...000a (data + length*2 = 10)
// If name = "a very long string that exceeds thirty one bytes":
// Slot 0 = length * 2 + 1
// keccak256(0) + 0 = first 32 bytes of data
// keccak256(0) + 1 = next 32 bytes of data
}
Structs and Inheritance
Struct Layout
Struct members follow the same packing rules as top-level state variables:
struct UserInfo {
address wallet; // 20 bytes
uint96 balance; // 12 bytes -- packed with wallet (total 32)
uint256 lastUpdate; // 32 bytes -- new slot
bool isActive; // 1 byte -- new slot
}
contract StructLayout {
UserInfo public user; // Starts at slot 0
// user.wallet + user.balance -> Slot 0 (packed)
// user.lastUpdate -> Slot 1
// user.isActive -> Slot 2
}
Inheritance Layout
With inheritance, the base contract’s variables occupy the lowest slots, and derived contracts continue from where the base left off:
contract Base {
uint256 public baseValue; // Slot 0
}
contract Derived is Base {
uint256 public derivedValue; // Slot 1
}
contract MultiInherited is Base, Derived {
uint256 public extraValue; // Slot 2
}
The linearization order follows Solidity’s C3 linearization algorithm, which processes base contracts from right to left in the inheritance list.
Storage Collisions in Proxy Contracts
Storage collisions are one of the most dangerous bugs in Ethereum smart contract development. They occur in proxy patterns where delegatecall executes implementation code in the proxy’s storage context.
The Problem
contract Proxy {
address public implementation; // Slot 0
address public admin; // Slot 1
}
contract ImplementationV1 {
uint256 public value; // Also Slot 0 -- COLLISION!
address public owner; // Also Slot 1 -- COLLISION!
}
When the proxy delegates to ImplementationV1, any read or write to value actually accesses the slot where implementation is stored. Writing to value would overwrite the implementation address, potentially bricking the proxy.
The Solution: EIP-1967
EIP-1967 defines specific storage slots for proxy data, computed from hash values that are astronomically unlikely to collide with normal variable slots:
// Implementation slot:
// bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1)
// = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
// Admin slot:
// bytes32(uint256(keccak256("eip1967.proxy.admin")) - 1)
// = 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
OpenZeppelin’s proxy contracts and the UUPS pattern both use EIP-1967 slots.
Gas Costs: Cold vs Warm Access (EIP-2929)
EIP-2929 (Berlin upgrade, April 2021) introduced the distinction between cold and warm storage access, dramatically changing the gas economics:
| Operation | Cold (First Access) | Warm (Subsequent) |
|---|---|---|
| SLOAD | 2,100 gas | 100 gas |
| SSTORE (0 to non-zero) | 22,100 gas | 20,000 gas |
| SSTORE (non-zero to non-zero) | 5,000 gas | 2,900 gas |
| SSTORE (non-zero to zero) | 5,000 gas + 4,800 refund | 2,900 gas + 4,800 refund |
The “accessed storage keys” set is maintained per-transaction. The first time a slot is read or written, it costs the cold price. Every subsequent access to that same slot within the transaction costs the warm price.
This means accessing the same storage slot twice in one transaction costs 2,100 + 100 = 2,200 gas total, not 2,100 + 2,100 = 4,200. Solidity’s optimizer takes advantage of this by caching storage reads in memory variables.
Gas Optimization Patterns
// BAD: Multiple cold reads
function bad() external view returns (uint256) {
return value + value + value; // 3 SLOADs: 2100 + 100 + 100 = 2300 gas
// Actually the compiler may optimize this, but the principle applies
// to separate functions or complex logic
}
// GOOD: Cache in memory
function good() external view returns (uint256) {
uint256 cached = value; // 1 SLOAD: 2100 gas
return cached + cached + cached; // Pure stack ops: 6 gas
}
Reading Storage Directly
The eth_getStorageAt JSON-RPC method lets you read any storage slot of any contract:
// Read slot 0 of a contract
const value = await provider.getStorage(
"0xContractAddress",
0 // slot number
);
// Read a mapping value
const mappingSlot = 1;
const key = "0xUserAddress";
const derivedSlot = ethers.keccak256(
ethers.AbiCoder.defaultAbiCoder().encode(
["address", "uint256"],
[key, mappingSlot]
)
);
const balance = await provider.getStorage(contractAddress, derivedSlot);
This capability means that nothing stored in a smart contract is truly private, even if variables are declared with the private visibility modifier. The private keyword only prevents other contracts from reading the value through Solidity function calls — anyone can read the raw storage slot.
Exploring Storage in the Zig EVM
The Zig EVM project implements storage as a HashMap-based key-value store, closely mirroring the EVM specification. You can observe storage operations at the opcode level using the Zig EVM Playground:
// Zig EVM storage implementation uses HashMap for O(1) access
// Storage keys and values are both 256-bit words
pub const Storage = std.HashMap(u256, u256, ...);
The implementation tracks cold/warm access following EIP-2929 semantics, making gas costs accurate for testing and educational purposes.
Key Takeaways
- EVM storage is a 256-bit key-value store with 2^256 possible slots per contract. Only written slots consume resources.
- Solidity assigns variables to sequential slots starting from 0 and packs smaller types into shared slots when consecutive variables fit within 32 bytes.
- Mapping values are stored at keccak256(key, slot), distributing entries across the storage space. No data is stored at the mapping’s declared slot.
- Dynamic array elements start at keccak256(slot) with the array length stored at the declared slot itself.
- Storage collisions in proxy contracts occur when the proxy and implementation assign different variables to the same slot. EIP-1967 prevents this by using hash-derived slots for proxy metadata.
- Cold storage access costs 2,100 gas (SLOAD) or 22,100 gas (SSTORE to empty), making storage the most expensive EVM resource. Caching reads in memory variables is a critical optimization.
- All storage is publicly readable via eth_getStorageAt, regardless of Solidity visibility modifiers. Never store secrets in contract storage.
Frequently Asked Questions
- How does EVM storage work?
- EVM storage is a persistent key-value store where both keys and values are 256-bit (32-byte) words. Each smart contract has its own isolated storage space with 2^256 possible slots. Solidity assigns state variables to sequential slots starting from slot 0, and uses keccak256 hashing to derive slot locations for mappings and dynamic arrays.
- What is variable packing in Solidity storage?
- Variable packing is a gas optimization where multiple state variables that together fit within 32 bytes are stored in a single storage slot. For example, two uint128 variables or a uint8 and an address can share one slot. Variables are packed in declaration order, right-aligned within the slot.
- How are Solidity mappings stored in the EVM?
- Solidity mappings do not occupy a contiguous block of storage. The mapping's declared slot p stores nothing. Instead, the value for a given key k is stored at the slot computed by keccak256(abi.encode(k, p)). This hash-based approach distributes values across the vast 2^256 storage space, making collisions statistically impossible.
- What is a storage collision in proxy contracts?
- A storage collision occurs when a proxy contract and its implementation contract assign different variables to the same storage slot. Since delegatecall executes implementation code in the proxy's storage context, overlapping slot assignments cause data corruption. EIP-1967 solves this by storing proxy-specific data at pseudorandom slots derived from specific hash values.
- How much gas does reading and writing storage cost?
- After EIP-2929, the first access (cold) to a storage slot costs 2,100 gas for SLOAD and 22,100 gas for SSTORE (setting a zero slot to non-zero). Subsequent accesses (warm) in the same transaction cost 100 gas for SLOAD and 5,000 gas for SSTORE. Writing to storage is the most expensive EVM operation, which is why variable packing and storage optimization matter.
Try Zig EVM
Explore the interactive playground or dive into the source code.