Stack, Memory, Storage, and Calldata
The previous page showed the EVM as a stack machine: a tower of 256-bit words that opcodes push, pop, and combine. But a stack alone cannot build a world computer. A stack is scratch space — it exists only while an expression is being evaluated, and it is gone the moment the call returns. If that were the only place data could live, no contract could remember anything, and Ethereum’s whole promise (“a shared world computer whose state untrusting strangers agree on”) would collapse into a very expensive calculator.
So the EVM gives a running contract four distinct places to put data, and the entire art of writing correct, affordable Ethereum code comes down to knowing which is which. They differ along three axes that this page will keep returning to: how long the data lives, what it costs to touch, and who is allowed to write it. Get the location right and your contract is cheap and correct. Get it wrong and you have either burned money or shipped a bug — and, as we will see, “getting the location wrong” is a named class of Solidity vulnerability.
The four locations at a glance
Section titled “The four locations at a glance”Here is the whole map before we walk each region. Read it as lifetime × cost × writability.
location lives for... cost to touch writable? survives call? -------- -------------------- --------------- --------- -------------- stack one expression ~3 gas / op yes no memory one call cheap, grows yes no calldata one call (read-only) cheap NO no storage forever very expensive yes YES ← the only oneOnly the last row survives. Everything above it is scratch: born when the call begins (or mid-expression, for the stack), erased when the call ends. That single distinction — storage persists, the rest evaporate — is the most important fact on this page, because it is the fact that costs money.
1. The stack — scratch operands
Section titled “1. The stack — scratch operands”The stack is where computation actually happens. It holds up to 1024 words, and every arithmetic or logic opcode reads its inputs from the top of the stack and pushes its result back. PUSH puts a literal on top; ADD pops two and pushes their sum. There is no way to index into the middle of the stack arbitrarily — you reach words near the top with DUP and SWAP, and that is all.
In our companion mini-EVM the stack is exactly a Vec<u64> (real EVM words are 256 bits; we simplify to 64 for teaching), and an ADD is nothing more than two pops and a push:
Op::Add => { let b = pop(&mut stack, "ADD")?; let a = pop(&mut stack, "ADD")?; push(&mut stack, a.wrapping_add(b))?; pc = next;}The stack is the cheapest place to have a value — a PUSH, POP, ADD, or SUB costs about 3 gas — and the shortest-lived: those two operands ceased to exist the instant ADD consumed them. Nothing on the stack outlives the expression that produced it. It is where numbers go to be used immediately and forgotten.
2. Memory — a scratchpad for one call
Section titled “2. Memory — a scratchpad for one call”The stack is fine for a handful of words, but you cannot build a hash of a 200-byte message, or ABI-encode a return value, or assemble the arguments for a call to another contract, using only a 1024-slot pushdown stack. For that the EVM gives each call a block of memory: a linear, byte-addressed array that starts empty (all zeros) and can be written and read at any offset (MSTORE, MLOAD).
Two properties define memory:
- It lives exactly as long as the current call. When the call returns, memory is discarded. The next call to the same contract starts with a blank memory again. Memory remembers nothing between invocations.
- It grows on demand, and growth costs gas. Memory is free at first but priced by how far out you write — expanding it costs gas that rises faster than linearly, so touching byte 1,000,000 is not the same price as touching byte 32. This is a deliberate ceiling on how much a single call can scribble.
Think of memory as the whiteboard you wipe clean at the end of every meeting. Very useful during the meeting; retains nothing after everyone leaves.
3. Calldata — the read-only input
Section titled “3. Calldata — the read-only input”When someone calls your contract, they hand it an input: the bytes of the transaction’s data field — which function to run and the arguments to run it with. Those bytes arrive as calldata.
Calldata is like memory in that it is per-call and byte-addressed, but it differs in one decisive way: the contract cannot write to it. Calldata is the input passed in from outside, and it is immutable for the duration of the call. You read it (CALLDATALOAD, CALLDATACOPY, CALLDATASIZE); you never modify it. If you need a mutable working copy, you copy calldata into memory first.
That read-only-ness is not a limitation to route around — it is a feature. Because calldata is immutable and does not need to be zero-initialised or grown, reading an argument straight from calldata is cheaper than first copying it into memory. In Solidity, marking a function parameter calldata instead of memory is a common, real gas optimisation for exactly this reason.
// `calldata` — read the argument in place, no copy. Cheaper.function sumInPlace(uint256[] calldata xs) external pure returns (uint256 s) { for (uint256 i = 0; i < xs.length; i++) s += xs[i];}
// `memory` — Solidity copies the whole array into memory first. Pricier,// but now you *could* mutate it.function sumCopied(uint256[] memory xs) public pure returns (uint256 s) { for (uint256 i = 0; i < xs.length; i++) s += xs[i];}4. Storage — the account’s permanent memory
Section titled “4. Storage — the account’s permanent memory”Here is the location that makes Ethereum a world computer rather than a stateless script runner. Storage is a persistent key→value map attached to the account: a mapping from a 256-bit key (a “slot”) to a 256-bit value. Two opcodes touch it: SSTORE writes a slot, SLOAD reads one. A slot that has never been written reads back as 0 — there is no “unset” versus “zero”; absence is zero.
In the mini-EVM, an account’s storage is literally a BTreeMap<u64, u64> (real EVM is u256 → u256), and SLOAD / SSTORE are as plain as it looks:
Op::SStore => { let key = pop(&mut stack, "SSTORE")?; let value = pop(&mut stack, "SSTORE")?; working.insert(key, value); // write the slot pc = next;}Op::SLoad => { let key = pop(&mut stack, "SLOAD")?; let value = working.get(&key).copied().unwrap_or(0); // unset ⇒ 0 push(&mut stack, value)?; pc = next;}What sets storage apart from the other three locations is everything:
- It survives between calls. A value written to storage in one transaction is still there in the next transaction, next block, next year. This is the only location a contract can use to remember anything.
- It is part of the world state. Every full node on Ethereum keeps every account’s storage, forever, and folds it into the state root — the single hash that all nodes must agree on (that root is exactly what the throughline “how do untrusting strangers agree on the state of a shared world computer” is asking about). Change one storage slot and the state root changes; that is why writing storage is a global, consensus-visible act and not a private scribble.
- It is the most expensive place to write, by a wide margin. Because an
SSTOREgrows the state that every node on Earth must store and re-hash forever, it is priced accordingly — orders of magnitude above a stack op. The gas page prices this precisely; the intuition to carry there is: you are renting space in tens of thousands of computers’ permanent memory.
Lifetime: three evaporate, one persists
Section titled “Lifetime: three evaporate, one persists”Line the four up by lifetime and the whole model snaps into focus:
call begins ──────────────────────── call ends │ │ stack: ▓▓ (per-expression, gone even sooner) memory: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ✗ discarded calldata: ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ ✗ discarded (was read-only) storage: ═══════════════════════════════════════════▶ persists foreverStack, memory, and calldata are all call-local: they come into being when execution starts, and the runtime throws them away when it stops. They cost gas to use, but they impose no lasting burden on the network, because nobody has to keep them. Storage is the opposite: cheap it is not, precisely because the network keeps it. The cost you pay for SSTORE is, in effect, a fee for permanently enlarging a shared, replicated database.
This is the single most useful lens for reasoning about EVM cost, and we will lean on it hard in Gas — A Price on Every Opcode: cheap operations are the ones the world can forget; expensive operations are the ones the world must remember.
Under the hood — storage writes commit only on a clean halt
Section titled “Under the hood — storage writes commit only on a clean halt”There is a subtlety in when a storage write becomes real, and it is the mechanism behind one of Ethereum’s most important guarantees: atomicity. Either a transaction’s effects all happen, or none of them do. A contract call cannot half-succeed, leaving storage in a mangled in-between state.
How is that enforced? A contract does not write straight through to the canonical storage while it runs. It writes to a working copy, and that copy is committed back to the account only if the call halts cleanly (a normal STOP or RETURN). If the call throws — out of gas, a bad opcode, an explicit REVERT, a stack underflow — the working copy is dropped and the original storage is untouched. Nothing was ever written, as far as the rest of the world is concerned.
Our mini-EVM makes this literal: run clones the storage up front and mutates only the clone; the caller commits it on success and simply drops it on error.
// vm::run — the revert mechanism in miniature.let mut working = storage.clone(); // mutate a COPY, never the original// ... execute opcodes, SSTORE writes into `working` ...// On STOP/RETURN we return Ok(Execution { storage: working, .. }).// On ANY error we return Err(..) — and `working` is dropped, unread.And the caller in state.rs commits only on Ok:
match vm::run(&code, &storage, gas_limit) { Ok(exec) => self.account_mut(*to).storage = exec.storage, // commit Err(_) => { /* keep original storage; report Reverted */ }}That “mutate a clone, commit or discard the whole thing” pattern is the revert. It is why a failed transaction can consume gas (the work was really done) yet leave no storage trace, and why you can call an untrusted contract without fear that a mid-way crash corrupts your data. We will spend a whole page on the consequences in Execution Context and the CALL Family.
The location-mismatch bug — getting this wrong in Solidity
Section titled “The location-mismatch bug — getting this wrong in Solidity”Because storage persists and memory does not, telling Solidity the wrong location for a variable is not a style nit — it changes what your code does. The classic trap is a local reference to a struct or array that you meant to be a live handle into storage but that the compiler treats as a throwaway memory copy (or vice versa):
struct User { uint256 balance; }mapping(address => User) users;
function credit(address who) external { // `storage`: `u` is a live pointer INTO the map. The write persists. User storage u = users[who]; u.balance += 100; // ✅ users[who].balance really increases}
function creditBroken(address who) external { // `memory`: `u` is a COPY. The write lands on the copy and is discarded // when the call ends — users[who] never changes. User memory u = users[who]; u.balance += 100; // ❌ silently a no-op on state}creditBroken compiles, runs, emits no error, and quietly does nothing durable — the update lands on a memory copy that evaporates at call’s end. Whole audits have turned on exactly this distinction. The lesson is the throughline in miniature: on a shared world computer, where a value lives decides whether the world remembers it, and the compiler will not save you from choosing wrong.
The architect’s lens
Section titled “The architect’s lens”- Why does it exist? A stack alone is a calculator; it forgets everything when a call returns. Ethereum needs contracts that remember — balances, ownership, votes — so the EVM adds persistent storage, and adds cheap, disposable memory/calldata so that not every scratch value has to pay the permanence tax.
- What problem does it solve? It lets a program distinguish “a number I need for two instructions” from “a fact the whole world must agree on for years,” and price each correctly — so ordinary computation stays cheap while durable state carries the cost it genuinely imposes on every node.
- What are the trade-offs? Four locations means four sets of rules and a real footgun: mixing them up (a
memorycopy where you meant astoragereference) is a silent bug, and over-using storage is a silent money leak. The model buys correctness and fair pricing at the cost of programmer vigilance. - When should I avoid it? Avoid storage whenever a value doesn’t need to outlive the call — keep loop counters and intermediate math on the stack or in memory, and read inputs from
calldatarather than copying them. Reach for storage only for state that must survive. - What breaks if I remove it? Remove storage and contracts can’t remember anything — no tokens, no balances, no world computer, just a stateless script. Remove the distinction (make everything persistent) and every trivial computation would cost as much as permanently enlarging a global database; the network would be unusably expensive and would bloat without bound.
Check your understanding
Section titled “Check your understanding”- Name the EVM’s four data locations. Which single one survives after the call that touched it ends, and why does that survival make it the expensive one?
- A contract receives an array argument and needs to sum it without changing it. Which location should it read from to be cheapest, and why can’t it write there?
- What does
SLOADreturn for a storage slot that has never been written? What does that imply about the difference between “unset” and “zero”? - Explain the mechanism that makes a reverted transaction leave storage untouched even though it may have executed an
SSTORE. Reference the “working copy” idea. - A colleague writes
User memory u = users[who]; u.balance += 100;and is baffled that balances never change. Diagnose the bug in terms of lifetime, and give the one-word fix.
Show answers
- Stack, memory, calldata, and storage. Only storage survives; the other three are call-local and discarded when the call ends. Storage survives because every full node keeps it forever and folds it into the state root — so a write is a permanent, consensus-visible enlargement of a globally replicated database, which is exactly why it is priced far above a stack or memory op.
- It should read the argument from calldata — the immutable input already sits there, so reading it in place avoids the cost of copying it into memory (in Solidity, marking the parameter
calldatarather thanmemory). It can’t write to calldata because calldata is the read-only input passed in from outside the call. SLOADof an unwritten slot returns0. There is no separate “unset” state: absence is zero. A slot reads as zero whether it was explicitly set to zero or never touched.- A running contract mutates a working copy (a clone) of the account’s storage, not the canonical storage directly. The copy is committed back only on a clean halt (
STOP/RETURN); on any exceptional halt orREVERT, the copy is dropped and the original storage is untouched. So even an executedSSTOREleaves no trace when the call throws — the write only ever landed on the discarded clone. uis declaredmemory, so it is a copy of the storage struct whose lifetime ends with the call;u.balance += 100updates that copy and then throws it away, never touchingusers[who]. The one-word fix: changememorytostorage, makingua live reference into the map so the write persists.