Deep EVM #2: Memory Model — Stack, Memory, Storage, and Calldata
Engineering Team
Four Places Your Data Can Live
The EVM provides four distinct data locations, each with radically different costs, lifetimes, and access patterns. Choosing the wrong one is the single most common source of excessive gas consumption in smart contracts.
| Location | Lifetime | Read Cost | Write Cost | Size |
|---|---|---|---|---|
| Stack | Current opcode | 0 (DUP: 3) | 0 (PUSH: 3) | 1024 words |
| Memory | Current call context | MLOAD: 3* | MSTORE: 3* | Expandable |
| Storage | Permanent (blockchain) | SLOAD: 2100/100 | SSTORE: 20000/2900/100 | 2^256 slots |
| Calldata | Current call context | CALLDATALOAD: 3 | Read-only | Transaction input |
*Memory costs 3 gas per operation plus a quadratic expansion cost.
The Stack: Fast but Tiny
The stack is the EVM’s working memory. Every arithmetic, comparison, and logical operation reads from and writes to the stack. It holds up to 1024 elements, each 32 bytes wide.
In practice, you rarely use more than 16 stack slots because DUP and SWAP opcodes only reach 16 elements deep. The Solidity compiler manages stack layout automatically, but in Yul and Huff you manage it manually — and “stack too deep” errors become your constant companion.
Stack access is essentially free (3 gas for PUSH/DUP/SWAP operations). This makes the stack the ideal location for intermediate computations, loop counters, and temporary values.
// Stack-only computation: sum of 1 + 2 + 3
// Total gas: 3 + 3 + 3 + 3 + 3 = 15 gas
let a := add(1, 2) // PUSH1 1, PUSH1 2, ADD
let b := add(a, 3) // DUP1, PUSH1 3, ADD
Memory: Cheap but Ephemeral
Memory is a byte-addressable array that starts empty and expands as needed. It persists only for the duration of the current call context — when a CALL, STATICCALL, or DELEGATECALL returns, the callee’s memory is destroyed.
Memory is accessed in 32-byte chunks:
- MSTORE(offset, value) — Write 32 bytes at the given offset. Costs 3 gas.
- MLOAD(offset) — Read 32 bytes from the given offset. Costs 3 gas.
- MSTORE8(offset, value) — Write a single byte. Costs 3 gas.
The Free Memory Pointer
Solidity reserves the first 128 bytes of memory for special purposes:
| Offset | Purpose |
|---|---|
| 0x00-0x3f | Scratch space (used for hashing) |
| 0x40-0x5f | Free memory pointer |
| 0x60-0x7f | Zero slot (used as initial value for dynamic memory arrays) |
| 0x80+ | Free memory (allocation starts here) |
The free memory pointer at 0x40 tracks the next available memory address. When Solidity allocates memory (e.g., new bytes(100), abi.encode(...), creating a struct in memory), it reads the pointer, uses that address, and advances the pointer.
// What Solidity does internally when you write:
// bytes memory data = new bytes(64);
// 1. Read free memory pointer: MLOAD(0x40) -> 0x80
// 2. Store array length at 0x80: MSTORE(0x80, 64)
// 3. Update free memory pointer: MSTORE(0x40, 0x80 + 32 + 64 = 0xE0)
// 4. Data lives at 0xA0 through 0xDF
Memory Expansion Cost
Here is where memory gets expensive. The gas cost of memory is not just 3 gas per MLOAD/MSTORE — there is a quadratic expansion cost charged when you access memory beyond the current high-water mark.
The formula (from the Yellow Paper):
memory_cost = (memory_size_words^2 / 512) + (3 * memory_size_words)
where memory_size_words = ceil(highest_accessed_byte / 32).
For small memory usage (under ~700 bytes), the cost is approximately linear at 3 gas per word. But it grows quadratically:
| Memory Used | Cost |
|---|---|
| 32 bytes (1 word) | 3 gas |
| 1 KB (32 words) | 98 gas |
| 10 KB (320 words) | 1160 gas |
| 100 KB (3200 words) | 29600 gas |
| 1 MB (32000 words) | 2,001,000 gas |
This quadratic growth means that contracts processing large arrays in memory can hit gas limits quickly. It is also why Solidity uses memory sparingly and prefers stack-based computation where possible.
Storage: Permanent but Expensive
Storage is the EVM’s persistent key-value store. Each contract has its own isolated storage space with 2^256 32-byte slots. Storage persists across transactions — it is the blockchain state.
Storage Layout in Solidity
Solidity assigns storage slots sequentially to state variables:
contract StorageLayout {
uint256 public x; // slot 0
uint256 public y; // slot 1
address public owner; // slot 2 (packed: 20 bytes)
bool public paused; // slot 2 (packed: 1 byte, same slot!)
mapping(address => uint256) public balances; // slot 3 (base)
// balances[addr] lives at: keccak256(addr . slot_3)
}
Mappings and dynamic arrays use keccak256 to derive their storage slot:
mapping[key]->keccak256(key || slot_number)array[index]->keccak256(slot_number) + index
Storage Costs (Post EIP-2929 + EIP-3529)
Storage is by far the most expensive data location:
| Operation | Cold | Warm |
|---|---|---|
| SLOAD | 2100 | 100 |
| SSTORE (zero to nonzero) | 22100 | 20000 |
| SSTORE (nonzero to nonzero) | 5000 | 2900 |
| SSTORE (nonzero to zero) | 5000 + 4800 refund | 2900 + 4800 refund |
Setting a storage slot from zero to a nonzero value costs 20000 gas because the Ethereum state trie must insert a new leaf node. This is why initializing a new mapping entry (like a token balance for a new holder) is so expensive.
Storage Packing
Solidity packs multiple small variables into a single 32-byte slot when possible. This is a critical optimization:
// BAD: 3 storage slots = 3 x SLOAD = 6300 gas (cold)
contract Unpacked {
uint256 a; // slot 0 (32 bytes)
uint256 b; // slot 1 (32 bytes)
uint256 c; // slot 2 (32 bytes)
}
// GOOD: 1 storage slot = 1 x SLOAD = 2100 gas (cold)
contract Packed {
uint64 a; // slot 0, bytes 0-7
uint64 b; // slot 0, bytes 8-15
uint64 c; // slot 0, bytes 16-23
}
Calldata: Read-Only and Cheap
Calldata is the input data sent with a transaction or a call. It is read-only, and accessing it is cheap (3 gas for CALLDATALOAD). For external function calls, Solidity ABI-encodes the arguments into calldata.
// transfer(address to, uint256 amount)
// calldata: 0xa9059cbb
// 0000000000000000000000001234567890abcdef1234567890abcdef12345678
// 0000000000000000000000000000000000000000000000000de0b6b3a7640000
// ^-- function selector (4 bytes)
// ^-- address, padded to 32 bytes
// ^-- uint256 amount
Using calldata instead of memory for function parameters avoids copying data and saves significant gas:
// BAD: copies entire array to memory
function sum(uint256[] memory arr) external returns (uint256) { ... }
// GOOD: reads directly from calldata
function sum(uint256[] calldata arr) external returns (uint256) { ... }
Transient Storage (EIP-1153)
EIP-1153 (Cancun upgrade, March 2024) introduced two new opcodes: TSTORE and TLOAD. Transient storage is like regular storage but it is automatically cleared at the end of the transaction.
Key properties:
- Same key-value model as storage (2^256 slots of 32 bytes)
- Costs 100 gas for both TLOAD and TSTORE (similar to warm SLOAD/SSTORE)
- Cleared after transaction completes — no permanent state change
- Accessible across call frames within the same transaction
This is perfect for:
- Reentrancy locks — Set a flag with TSTORE, check with TLOAD. No 20000 gas SSTORE cost.
- Cross-contract communication within a transaction — Callback patterns, flash loans.
- Temporary approvals — ERC-20 temporary allowances within a single transaction.
// Before EIP-1153: reentrancy lock costs ~5000-20000 gas
mapping(address => bool) private _locked;
// After EIP-1153: reentrancy lock costs ~200 gas
assembly {
if tload(0) { revert(0, 0) } // check lock
tstore(0, 1) // set lock
}
// ... function body ...
assembly {
tstore(0, 0) // clear lock
}
Returndata: The Fifth Location
There is actually a fifth data location: returndata. After a CALL, STATICCALL, or DELEGATECALL completes, the callee’s return data is available via RETURNDATASIZE and RETURNDATACOPY. This data exists only until the next call opcode overwrites it.
// Read return data after an external call
let success := call(gas(), target, 0, 0, 0, 0, 0)
let size := returndatasize()
let ptr := mload(0x40)
returndatacopy(ptr, 0, size)
Practical Optimization: Choosing the Right Location
Here is a decision framework for MEV bot developers:
- Can it live on the stack? Use the stack. Zero marginal cost.
- Do you need more than 16 values? Use memory. Allocate from the free memory pointer.
- Is it read-only input data? Use calldata. 3 gas per 32-byte read.
- Must it persist across transactions? Use storage. Budget 20000+ gas for new entries.
- Must it persist across call frames but not across transactions? Use transient storage. 100 gas.
Conclusion
The EVM’s memory model is deceptively simple — four locations with clear tradeoffs. But the cost differences are enormous: a stack ADD costs 3 gas while an SSTORE costs 20000. Understanding these costs at a visceral level is what separates gas-efficient contracts from gas-burning ones. In the next article, we will quantify gas costs precisely and explore optimization patterns that can save thousands of gas per call.