Skip to main content
BlockchainMar 28, 2026

Deep EVM #2: Memory Model — Stack, Memory, Storage, and Calldata

OS
Open Soft Team

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.

LocationLifetimeRead CostWrite CostSize
StackCurrent opcode0 (DUP: 3)0 (PUSH: 3)1024 words
MemoryCurrent call contextMLOAD: 3*MSTORE: 3*Expandable
StoragePermanent (blockchain)SLOAD: 2100/100SSTORE: 20000/2900/1002^256 slots
CalldataCurrent call contextCALLDATALOAD: 3Read-onlyTransaction 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:

OffsetPurpose
0x00-0x3fScratch space (used for hashing)
0x40-0x5fFree memory pointer
0x60-0x7fZero 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 UsedCost
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:

OperationColdWarm
SLOAD2100100
SSTORE (zero to nonzero)2210020000
SSTORE (nonzero to nonzero)50002900
SSTORE (nonzero to zero)5000 + 4800 refund2900 + 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:

  1. Can it live on the stack? Use the stack. Zero marginal cost.
  2. Do you need more than 16 values? Use memory. Allocate from the free memory pointer.
  3. Is it read-only input data? Use calldata. 3 gas per 32-byte read.
  4. Must it persist across transactions? Use storage. Budget 20000+ gas for new entries.
  5. 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.