Introduction
Think of Ethereum contract storage like a giant library with infinite shelves, where each shelf (slot) is exactly 32 bytes wide. Many developers new to Solidity imagine storage working like a continuous bookshelf where books (data) are placed one after another. However, Ethereum's storage system is more like a magical library where books are placed according to specific rules that optimize space and access. Let's explore how this system works…
One of the most common misconceptions for newcomers to Solidity is to treat contract storage as if it were a traditional linear array of bytes (like in C or C++). In reality, Ethereum contract storage is more like a massive key-value store, conceptually a “map” from a 256-bit slot index to a 256-bit word. Internally, the blockchain stores this in a Merkle Patricia Tree (MPT), but for everyday Solidity usage, you can think of it as a mapping of slotIndex -> 32-byte data
.
Table of contents:
Simple values
Consider a contract with two consecutive uint256
fields:
contract Test {
uint256 public first;
uint256 public second;
}
We might be tempted to think of first
at byte offset 0x00
and second
at byte offset 0x20
(i.e., 32 bytes later):
0x00: first
0x20: second (0x20 is hexa for 32 bytes)
However, Solidity’s storage model assigns them to consecutive slots (not consecutive bytes) numbered 0
and 1
, each slot being 32 bytes:
Root (Merkle Patricia Tree)
├── Slot 0x00 → <value of first>
└── Slot 0x01 → <value of second>
Packing Smaller Types
If you change both fields to smaller types, say uint16
:
contract Test {
uint16 public first;
uint16 public second;
}
Because these two uint16
values fit into a single 32-byte slot, the Solidity compiler packs them. Both first
and second
are stored in the same 32-byte slot:
Root (Merkle Patricia Tree)
└── Slot 0x00 → <16 bits for first> || <16 bits for second>
Mappings
Mappings also have a “base slot,” but each entry is stored at a slot calculated via a hash of the key concatenated with that base slot. For example:
contract Test {
mapping(address => bool) public whitelisted;
}
The compiler assigns the base slot to whitelisted
(say slot 0
), but each specific address
key is stored at:
keccak256(abi.encode(key, base_slot))
So, if we added whitelisted[0xb0B] = true
, the storage becomes:
Root (Merkle Patricia Tree)
├── Slot 0x00 → <base slot for whitelisted>
└── Slot keccak256(0xb0B || 0x00) → true
Structs
Structures are also allocated by slots, starting from the struct’s “base slot,” with their fields laid out in place sequentially (packing them when possible). For instance:
contract Test {
struct Data {
uint256 first;
uint256 second;
}
Data public myData;
}
If myData
is placed at slot 0
, myData.first
is in slot 0
and myData.second
is in slot 1
:
Root (Merkle Patricia Tree)
├── Slot 0x00 → <value of myData.first>
└── Slot 0x01 → <value of myData.second>
Structs inside Mappings
If a mapping stores structs, each struct base is given by the mapping key hash, and then each field of the structs are consecutive in slots. Consider the following contract:
contract Test {
struct Data {
uint256 first;
uint256 second;
}
mapping(address => Data) public info;
}
The mapping info
itself might be assigned to slot 0
.
Then, for a particular key addr
, the struct’s base slot will be:
Open image-20241223-182051.png
The first field (info[addr].first
) is at that hashed slot, and the second field (info[addr].second
) is at the next consecutive slot (hashed slot + 1).
Hence:
Root (Merkle Patricia Tree)
└── Slot 0x00 → <base slot for mapping info>
└── Slot keccak256(addr || 0x00) → <value of info[addr].first>
└── Slot (keccak256(addr || 0x00) + 1) → <value of info[addr].second>
Contract Upgrades and Storage
When you upgrade a contract (commonly via the proxy pattern or delegatecall-based approaches), the existing storage layout must remain consistent. The new implementation code will continue to read and write from the same slots in the proxy’s storage. Therefore:
Changing the Order of State Variables
If you reorder or remove variables in your new implementation, you risk corrupting stored data (e.g., a new
uint256
might accidentally overwrite what used to be amapping
). That also applies to struct fields.Adding New Variables
Typically, you should append new state variables at the end of your existing variable list. This ensures that the slot usage for old variables does not change.
“Storage Gaps”
Some upgradable contract designs include “storage gap” variables—arrays of unused fixed-size slots—to reserve space for future expansions. Doing so helps avoid collisions in storage if you add new data in subsequent upgrades.
Always Keep the Same Compiler and Language Settings, If Possible
Slight variations in compiler versions or optimization settings may alter how the layout is determined.
🔑 Key Takeaway: Solidity automatically optimizes storage by packing multiple small variables into a single slot when possible.
🚫 Common Pitfall: Be careful with ordering! Putting a uint256 between two uint8s will prevent packing:
In summary, Solidity’s storage is not a simple array of bytes. Instead, it’s a conceptual key-value store where each 256-bit “slot” is numbered. The compiler decides how to pack values (especially when you use smaller types like uint16
) to optimize usage of these 256-bit slots. Mappings get assigned a base slot, and each key-value pair inside them resides at a hashed position (keccak256(key, base_slot)
). Structs occupy consecutive slots starting from the struct’s own base slot.
When upgrading contracts, you must preserve the layout of existing storage—or carefully introduce new slots while respecting the old layout—to maintain consistent data across upgrades.