How Ethereum storage slots work
An explainer showing how the Ethereum storage slots work
Table of Contents for How Ethereum storage slots work
Introduction guide to Slots on the Ethereum EVM
- In the EVM (Solidity codes compiles to EVM opcodes), here are a couple of types of places that data can be stored - memory and storage.
- Memory is cheap (to read and write), but is not stored on the block chain.
- Storage is expensive (to read and write), and is stored on the block chain.
- Storage is made up of slots.
How the slots are structured
- There are 2^256 available slots (available for each smart contract)
- (This is a stupidly huge number)
- Each slot is 32 byte
- You can think of it sort of like a key/value mapping. In Javascript, it could be like this:
// (this is a very simplified and quite inaccurate representation!)
const storageData = {
1: slotOneData, // (32 bytes)
2: slotTwoData, // (32 bytes)
// ...
[2 ** 256]: finalSlotData // (32 bytes)
}
- But the full ‘array’ (it isn’t really an array) of slots isn’t initalized - only the used slots are initalized.
- If you try to access a slot that wasn’t initalized yet, it would return a
0
value - Storage slots with just
0
data (all 32 bytes or 256 bits set to0
) cost nothing to store.
How the ordering of slots is important
- When you have multiple storage data variables, at compile time your solidity code will put the variables in order into slots.
contract Example {
uint256 firstNum; // slot 1 (total used: 256 bits)
uint256 secondNum; // slot 2 (total used: 256 bits)
uint128 thirdNum; // slot 3 (total used so far: 128 bits)
uint64 fourthNum; // slot 3 (total used so far: 128+64 bits)
uint64 fifthNum; // slot 3 (total used so far: 128+64+64 = 256 bits)
uint8 sixthNum; // slot 4 (total used 8 bits)
uint256 seventhNum // slot 5 (could not fit 256 bits into the remaining 248 bits in slot 4)
}
For this reason, be sure to order them in an optimized way. (See more tips here about optimizing for the EVM)
For example instead of ordering like this:
- first variable: 128 bytes (slot 1)
- second variable: 256 bytes (slot 2)
- third variable 128 bytes (slot 3)
The above would use 3 slots - first holding 128 bytes variable only, second holding the 256 bytes, and the third holding the 128 byte value.
You should instead order them so the two 128 bytes can be stored in the same slot:
- first variable 256 bytes (slot 1)
- second variable: 128 bytes (slot 2)
- third variable: 128 bytes (slot 2)
futher optimizations when packing slots
As well as organising them into an order so they can fit into small number of slots, you should also pack them so that variables that are often accessed together are in the same slot (as you pay for cold slot reads - but if they’re both in the same slot this means you have fewer cold slot reads)
How to get the slot number of a variable with Yul
Note: it is unlikely you will need to do this except in very rare cases. But understanding how you can access the slots will hopefully help understand slots better in general.
Using Yul (Solidity assembly) you can access the slot number quite easily.
In the next example there are 3 variables, a 256 byte uint, and two 32 byte uints.
Note: They are indexed from slot position 0, but generally in documentation you will see the first slot referred to as slot 1.
// SPDX-License-Identifier: GPL-3.0
pragma solidity >=0.8.4;
contract Slots {
uint256 varA;
uint32 varB;
uint32 varC;
constructor() {
varA = 1000;
varB = 2000;
varC = 3000;
}
function slotNumOfVarA() public pure returns (uint256) {
uint slotNum;
assembly {
slotNum := varA.slot
}
return slotNum; // slot 0
}
function slotNumOfVarB() public pure returns (uint256) {
uint slotNum;
assembly {
slotNum := varB.slot
}
return slotNum; // slot 1
}
function slotNumOfVarC() public pure returns (uint256) {
uint slotNum;
assembly {
slotNum := varC.slot
}
return slotNum; // slot 1
}
function getVarAtSlot(uint256 slotNum) view external returns(uint256) {
uint256 slotValue;
assembly {
slotValue := sload(slotNum)
}
return slotValue;
/*
if you call getVarAtSlot(0) it will return 1000
If you call getVarAtSlot(1) it will return 12884901890000
(as both uint32 are stored there, along with 192 of empty data)
*/
}
}
Note: you can use
varA.offset
to get offsets within a slot
The final function in the example above will use sload(slotNum)
to load a slot (256 bytes) from storage.
As there are 3 variables, but stored in 2 slots, one of them (slot at index 1 (2nd slot)) returns 12884901890000
as both of the uint32 (total 64 bytes) are stored in that 256 byte slot.
How storage of arrays and mappings work in Solidity
So far i’ve covered simple value types, all under 32 bytes (256 bits). These are pretty straight forward with how they get stored.
But arrays and mappings are a bit more complex when it comes to storage in Solidity.
Fixed size arrays
If you have storage like this:
contract SomeContract {
uint256[2] arrayOfNumbers; // 2x uint256
}
This will be stored the same as if you had two separate uint256s, something like this:
contract SomeContract {
uint256 numberOne;
uint256 numberTwo;
}
But variable length arrays (e.g. uint256[] allYourNumbers
) are stored in a different way. They also have the .length
property in Solidity, which has to be stored.
There are two main ways that variable length arrays are stored.
when each item in the array is 32 bytes (e.g. uint256[]
- 32 bytes):
To calculate the location in storage of an item in an array at a specific index (total storage addresses available: 2^256 - so a huge number of keys), you have to do a couple of things:
- get the slot of the variable
- keccak256 hash that slot
- turn that into a uint, and then add the index
e.g.
pragma solidity >=0.8.4;
contract ManuallyAccessArrayExample {
uint256[] yourNumbers = [13, 19];
function atIndex(uint256 index) public view returns(uint256) {
// get the slot number (in this case it will be 0) of the 'yourNumbers' array
uint256 slotNum;
assembly {
slotNum := yourNumbers.slot
}
// get the keccak256 hash of the slot number
bytes32 storageLocation = keccak256(abi.encode(slotNum));
uint valueAtIndex;
assembly {
// add the index (e.g. 0, or 1) to the storageLocation
// then load from storage from that location.
valueAtIndex := sload(add(storageLocation, index))
}
return valueAtIndex;
}
}
When multiple items in an array can be packed together (e.g. uint64[]
, as we can pack 4x 64 bytes into 256 bytes)
If we have an array that is a size that means multiple elements in the array can be packed together into one storage slot - in other words if 2 or more fit into 256 bytes, then the logic is a bit different.
This has the advantage of saving storage space, but with a tiny bit more logic to get that data out. Of course if you use Solidity, its handled automatically so you don’t often have to think about it.
How mappings are stored/accessed
Mappings are stored in a similar way as arrays (see above). You take a keccak hash of the mapping key and the slot number)
pragma solidity >=0.8.4;
contract ManuallyAccessArrayExample {
mapping(address => uint256) balances;
constructor() payable {
balances[msg.sender] = 55; // some example data so you can query for it
}
function balanceOf(address who) public view returns(uint256) {
// get the slot number of the balances mapping:
uint256 slotNum;
assembly {
slotNum := balances.slot
}
// get storage location, based on the key ('who') and the slot num
bytes32 storageLocation = keccak256(abi.encode(who, uint256(slotNum)));
uint balanceForAddress;
assembly {
// load the 32bytes from that location:
balanceForAddress := sload(storageLocation)
}
return balanceForAddress;
}
}
Nested mappings in Solidity generate the storage location by passing in the keccak hash of the child mapping instead of the uint256(slotNum)
.
Gas cost of accessing storage data
Loading data from storage is quite simple to work out the gas:
SLOAD
loads storage data (from a 32 byte key), and returns a 32 byte (256 bit) value.- If the accessed address is warm (already accessed in the current transaction), the cost is 100.
- If it is ‘cold’ (not yet accessed in the transaction) then it costs 2100.
- BTW: If the value at the key was never written to before, it will load
0
- BTW: If the value at the key was never written to before, it will load
But calculating gas for writing data to storage is a bit more complex
SSTORE
will store a 32 byte value, at a 32 byte key.- If the
new value
is the same ascurrent value
:- costs 100 gas
- If the
new value
is the same as theoriginal value
:- If
original value
was 0 (so we are changing from empty data, to new data) then gas is 20000 - otherwise (updating existing data) 2900 gas
- If
- otherwise 100 gas
In addition to the above, if the slot is cold then add another 2100 gas.
If you are writing to storage data, and turning a previously existing data into zero data you can get a gas refund.
Spotted a typo or have a suggestion to make this crypto dev article better? Please let me know!
Next post
Previous post
📙 Solidity Auditing online quiz
Learn how to audit smart contracts by looking at some example code and trying to find the bugs
⛽ Solidity Gas Optimizations Guide
How to optimize and reduce gas usage in your smart contracts in Solidity
🧪 Guide to testing with Foundry
Guide to adding testing for your Solidity contracts, using the Foundry and Forge tools
📌 Guide to UTXO
UTXO and the UTXO set (used by blockchains such as Bitcoin) explained
📐 Solidity Assembly Guide
Introduction guide to using assembly in your Solidity smart contracts
📦 Ethereum EOF format explained
Information explaining what the upcoming Ethereum EOF format is all about