An ordinary transaction enters contract A, and A hands execution to implementation contract B with DELEGATECALL. The bytecode that runs comes from B, yet address(this) returns A's address, msg.sender is the account that originally sent the transaction, and storage writes inside B land in A's slots. Swap DELEGATECALL for CALL and every one of those values changes; swap in STATICCALL and any write operation throws immediately.
At the bytecode level the four call instructions differ by a single opcode number, yet their semantic differences run through proxy upgrades, library calls, read-only queries, and reentrancy protection. This piece compares them one by one across execution context, storage ownership, message fields, and gas, and explains why DELEGATECALL became the foundation of proxy contracts, why STATICCALL needed an EIP of its own to introduce it, and why CALLCODE went from a member of Frontier to a deprecated leftover.
Opcodes and stack arguments of the four instructions
Line up the appearances first. CALL is 0xf1, CALLCODE is 0xf2, DELEGATECALL is 0xf4, and STATICCALL is 0xfa. All four push 0 onto the stack on failure and 1 on success, all describe their input and output regions with a memory offset and a length, all are bounded by the 1024-level call depth limit, and all fail on insufficient gas rather than silently truncating.
The number of stack arguments splits them into two groups. CALL and CALLCODE each take 7 operands: gas, the target address, value, the input offset, the input length, the output offset, and the output length. DELEGATECALL and STATICCALL each take 6, with no value entry: DELEGATECALL carries over the parent scope's msg.value, while STATICCALL pins it to 0. The differences in the argument table are themselves the way into the semantics. Of the two instructions that can set value themselves, one really transfers it (CALL) and one does not (CALLCODE); of the two that cannot set value, one inherits it (DELEGATECALL) and one zeroes it (STATICCALL).
How return data is handled is worth comparing too. All four write the child call's return data into the memory region the caller specifies, but the number of bytes written is capped by the out_size the caller gives. After the Byzantium upgrade introduced RETURNDATASIZE and RETURNDATACOPY (EIP-211), callers no longer have to guess the size of the return data in advance: they can read the length first and copy as needed, so proxies and general-purpose forwarding contracts no longer have to reserve a large enough output region.
One by one: where the code comes from, where state is written, whose message fields they are
CALL creates a new execution context. The code comes from the target address, storage also belongs to the target address, address(this) is the target contract, and msg.sender is the current contract that issued the call. The ether specified by value is genuinely transferred from the current contract to the target address, so the current contract's balance must be sufficient, or the call fails.
CALLCODE also fetches the target address's code to execute, but execution lands in the current account's context: storage belongs to the current contract, and address(this) is the current contract's address. It has one more counterintuitive trait than CALL: value can be any figure the caller specifies, so msg.value can be rewritten to a number different from the parent scope's, while the supposed value transfer is the current account paying itself and produces no actual balance change. The value check still has to be made: the Yellow Paper's definition of 0xf2 requires, as a precondition of the call, that value not exceed the current account's balance, otherwise the callee's code is not entered and the stack gets 0. CALLCODE's msg.sender is the current contract executing it, the same as CALL, and that is the key divergence from DELEGATECALL.
DELEGATECALL likewise executes the target code in the current account's context, with storage and address(this) both pointing at the current contract, but it carries the parent scope's msg.sender and msg.value into the child scope unchanged. EIP-7 puts it this way: sender and value propagate from the parent scope to the child scope, and CALLER and VALUE behave in the child code exactly as in the parent environment. That brings a direct benefit: an implementation contract can freely reference msg.sender and msg.value without being specially recompiled for compatibility with proxy calls. EIP-7's motivation section also lists two uses: splitting implementation code into several segments executed in stages to get around the roughly 3 million gas call limit of the time, and storing the code source in a mutable address to forward calls through that address.
The execution context STATICCALL creates is similar to CALL's: code and storage both belong to the target address, address(this) is the target contract, msg.sender is the caller, and msg.value is 0. The difference is that it stamps the child scope with a static flag, forbidding any state modification while it runs. The prohibited operations EIP-214 lists include CREATE, CREATE2, LOG0 through LOG4, SSTORE, SELFDESTRUCT, and a CALL carrying a non-zero value; encountering any of these throws immediately instead of performing the modification. The specification keeps one exception: CALLCODE does not count as a state modification even with a non-zero value, because under CALLCODE's semantics that value never leaves the current account.
A comparison table
In the table below, the message fields are the values observed inside the called code, and address(this) is the result ADDRESS pushes onto the stack when the target code executes.
| Instruction | Opcode | Stack arguments | Code source | storage owner | address(this) | msg.sender | msg.value | Writable state |
|---|---|---|---|---|---|---|---|---|
| CALL | 0xf1 | 7 (includes value) | Target address | Target address | Target address | Current contract | Specified value, actually transferred | Allowed |
| CALLCODE | 0xf2 | 7 (includes value) | Target address | Current contract | Current contract | Current contract | Specified value; must pass the balance check but is not actually transferred | Allowed |
| DELEGATECALL | 0xf4 | 6 | Target address | Current contract | Current contract | Inherited from the parent scope | Inherited from the parent scope | Allowed |
| STATICCALL | 0xfa | 6 | Target address | Target address | Target address | Current contract | Fixed at 0 | Prohibited |
Gas: base cost, access cost, and the 63/64 retention rule
The gas for the four instructions stacks up from several parts: the base cost, the target address's access cost, the memory expansion cost, and the allowance actually handed to the child call. Whatever the child call does not consume is returned, so it is closer to a credit line than to an expenditure.
After the Berlin upgrade (EIP-2929), the target address's access cost switched to cold/warm pricing: the first access within a transaction is a cold access costing 2600 gas, and a further access is a warm access costing 100 gas. Earlier, EIP-150 had raised the base cost of CALL, CALLCODE, and DELEGATECALL uniformly to 700 gas; after Berlin the call family moved to cold/warm pricing, with 2600 and 100 replacing the flat 700, and the pricing happens before the available gas is computed. The address set is shared within a single transaction, and if execution at some level reverts, the access records that level added revert along with it. A caller that wants to save this cost can attach an EIP-2930 access list to the transaction, declaring in advance the addresses and slots it will touch, at the price of a fixed fee per entry.
Value and account creation add surcharges. A CALL carrying a non-zero value costs an extra 9000 gas; if the recipient is a dead account (nonexistent or empty) and therefore has to be created in the state tree, add another 25000. CALLCODE likewise adds 9000 for a non-zero value, except that its value goes to the current account and no new account is involved. DELEGATECALL and STATICCALL have no value argument, so neither surcharge applies.
The 2300 gas stipend is another point that is easy to get wrong. A CALL or CALLCODE carrying a non-zero value gives the child call an extra 2300 gas; the Yellow Paper's fee schedule records this allowance as “subtracted from G_callvalue,” meaning it is included in the 9000 value-transfer surcharge rather than being a subsidy on top of it. It is also the source of the gas cap on transfer() and send(): those two methods forward only 2300 gas, so they fail as soon as the recipient needs to write storage or emit a log. The Solidity documentation has already marked send() and transfer() as discouraged and slated for removal, recommending CALL with an explicit check of the return value instead.
EIP-150 also introduced the “retain 1/64” rule: when the gas requested for forwarding exceeds 63/64 of the caller's remaining available gas, only 63/64 is actually forwarded, and the parent scope always keeps a share of gas to handle a failure return. This rule replaced the earlier attack surface based on call depth with one based on gas, and it explains why a parent call can still obtain the failure flag and keep executing even when the child call exhausts its gas, instead of the whole transaction running out together.
DELEGATECALL is the foundation of proxy contracts, at the cost of storage layout constraints
DELEGATECALL is what makes the proxy pattern possible: the proxy contract holds the state and the assets and forwards calls to the implementation contract, the implementation reads and writes the proxy's storage in the proxy's context, and an upgrade changes only the implementation address while the state stays in place. This is also the standard way to build upgradeable contracts on the EVM. A minimal forwarding function looks roughly like this, with the implementation address passed in as an argument and deliberately not occupying storage, to avoid clashing with the implementation contract's layout.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Forwarder {
function _delegate(address impl) external payable {
assembly {
calldatacopy(0, 0, calldatasize())
// Run impl's code in the caller's (the proxy's) context; storage and msg.sender stay on the proxy side
let ok := delegatecall(gas(), impl, 0, calldatasize(), 0, 0)
returndatacopy(0, 0, returndatasize())
switch ok
case 0 { revert(0, returndatasize()) }
default { return(0, returndatasize()) }
}
}
}
The price is that the storage layout must line up. DELEGATECALL does not rename or migrate slots; the code reads and writes by slot number or by the offset the compiler assigned. If the proxy declares a variable of its own and the implementation contract happens to declare a variable in the same slot, the two overwrite each other. EIP-1967 therefore writes the implementation address at the slot computed by bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1), namely 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc, a position that in all probability no compiler will assign to a business variable. By the same token, a new state variable in the implementation contract can only be appended at the end; removing or reordering variables throws the reads and writes after the upgrade out of alignment across the board.
Constructors carry a specific constraint under the proxy pattern too. When the implementation contract is deployed, its constructor runs in its own context and writes the implementation contract's own storage, not the proxy's state. Initializing the proxy must therefore go through an explicit initializer function, guarded by a run-once flag; otherwise anyone can reinitialize it and seize control.
There is another class of risk that comes from the called code itself. DELEGATECALL gives the target code all of the proxy's privileges: it can write any slot, move out the balance, call other contracts, and even affect the proxy's executability through selfdestruct. If the implementation address can be changed freely, or an unaudited library is pulled in, that amounts to handing over control of the proxy wholesale. Public functions of a Solidity library are invoked with DELEGATECALL for exactly this reason, so library code cannot declare state variables of its own, or it would clash with the caller's layout; a library's non-view, non-pure functions are designed to be callable only through DELEGATECALL, their runtime code carries a call guard, and issuing a CALL directly at a library address reverts at runtime (calling view or pure functions is not subject to this guard).
STATICCALL turns state modification into an exception
STATICCALL was introduced by EIP-214 and activated with the Byzantium upgrade. EIP-214 gives only the opcode and its semantics without naming the fork; the fork attribution comes from EIP-609, which listed EIP-214 in the Byzantium inclusion list. The motivation is that after an ordinary CALL the caller cannot assume the callee's state has not changed, which makes reentrancy-style problems hard to reason about locally. EIP-214 states its goal as keeping the state of all accounts consistent before and after a static call, so that the call can be seen as a pure function that returns output without side effects.
The implementation adds a STATIC flag to the EVM. The flag defaults to false and is normally copied as is on entering a child call; only STATICCALL sets it to true, restoring it on return. The prohibited operations were listed in the previous section, and the key point is that the flag propagates along the call chain: a CALL issued inside a static call leaves the child call in static mode as well, so adding another layer of indirection does not get around the restriction.
There are two classes of engineering benefit. Read-only queries can be composed safely, so price views and multi-contract aggregate calls need not worry about the callee rewriting state; and reentrancy protection can be closed off earlier, because once an external read-only call is placed in static mode, a reentrant write fails directly at the EVM layer. Since Solidity 0.5.0, calls to non-library view and pure functions compile to STATICCALL by default, adding a runtime backstop on top of the compile-time constraint. A library's view functions are the exception: they still use DELEGATECALL, because the EVM has no instruction with both static and delegate semantics, so a library's read-only functions have no runtime state protection, a point that has to be confirmed separately during an audit.
The boundaries deserve to be drawn just as clearly. STATICCALL forbids state modification only; it does not forbid reads or callbacks, so it blocks the write path of reentrancy without making the reentrancy problem disappear as a whole. Nor can it replace the compile-time checks of view / pure; the two cover different failure modes. A static call consumes gas just the same, and the target contract can deliberately revert to turn the caller's fees into an attack cost; static mode guarantees only that state does not change, not that the call succeeds or that the result can be trusted.
CALLCODE's historical place and exit
CALLCODE is an instruction that has existed since Frontier, and EIP-7's motivation section treats it directly as DELEGATECALL's counterpoint. Its design goal is close to DELEGATECALL's — “borrow someone else's code and run it on the current account” — but it handles the message fields differently: msg.sender is rewritten to the current contract, and msg.value can be set to anything. When the original sender and value need to be passed through, it cannot do the job, and EIP-7 filled that gap by propagating sender and value.
The muddle of its value semantics hastened its exit. CALLCODE with a non-zero value requires the current account to have a sufficient balance, yet hands the value to its own address, so the child call reads a rewritten msg.value and nothing moves on the books — behavior that is hard to reason about intuitively. As for actual usage, the author of EIP-2488 holds that CALLCODE was never really used, and the proxy pattern's widespread adoption came later still (DELEGATECALL shipped with Homestead, and EIP-7 gives 1,150,000 as the mainnet activation block); both points are judgments based on the public timeline and the author's statement, lacking quantifiable call statistics.
The exit came in two steps. Since Solidity 0.5.0, callcode is no longer allowed, and only inline assembly can still emit the 0xf2 instruction. The protocol layer never actually removed the opcode: EIP-2488 proposed making CALLCODE fail unconditionally after a certain block height, on the grounds that deleting the opcode outright would abort contracts that encounter it, whereas returning failure gives contracts a chance to notice and recover; that proposal remains in Stagnant status to this day. The conclusion: CALLCODE has exited at the language level while remaining, at the bytecode level, a legacy semantic that implementations must handle correctly — the two should not be conflated.
Call context decides which slot a state write lands in
All four instructions ultimately answer the same question: in whose context does this code run, and into whose storage does it write. CALL and STATICCALL write state into the called contract; DELEGATECALL and CALLCODE write into the calling contract. For single-threaded execution this distinction affects correctness only; for parallel execution it determines the input to conflict detection. To judge whether two transactions interfere with each other, the scheduler must know which “address plus storage slot” combinations they ultimately write, and the call context is precisely what decides whether the address there is the proxy or the implementation: under the proxy pattern, one forwarding call writes the proxy's slots, not the implementation contract's. How conflict detection is organized around the same storage slot is left for a later discussion of storage layout and read-write sets.
Sources
- EIP-7, DELEGATECALL, the
0xf4opcode, propagation of sender and value, the Homestead activation block 1,150,000, and the historical motivation: https://eips.ethereum.org/EIPS/eip-7 - EIP-214, New opcode STATICCALL, the static flag, the list of prohibited operations, and the CALLCODE exception: https://eips.ethereum.org/EIPS/eip-214
- EIP-609, Hardfork Meta: Byzantium, the meta-proposal that listed EIP-211 and EIP-214 in the Byzantium inclusion list: https://eips.ethereum.org/EIPS/eip-609
- EIP-211, New opcodes: RETURNDATASIZE and RETURNDATACOPY, dynamically sized return data and
BYZANTIUM_FORK_BLKNUM: https://eips.ethereum.org/EIPS/eip-211 - EIP-150, Gas cost changes for IO-heavy operations, the 700 call base cost and the 63/64 retention rule: https://eips.ethereum.org/EIPS/eip-150
- EIP-2929, Gas cost increases for state access opcodes, the 2600 cold access, the 100 warm access, and the access set: https://eips.ethereum.org/EIPS/eip-2929
- ERC-1967 (originally EIP-1967), Proxy Storage Slots, the implementation slot
keccak256("eip1967.proxy.implementation") - 1: https://eips.ethereum.org/EIPS/eip-1967 - EIP-2488, Deprecate the CALLCODE opcode, the motivation for always returning failure, and the Stagnant, unactivated status: https://eips.ethereum.org/EIPS/eip-2488
- Ethereum Yellow Paper, appendix H's definitions and opcode table for CALL / CALLCODE / DELEGATECALL / STATICCALL (including CALLCODE's
value ≤ balanceprecondition), and appendix G's fee schedule: https://ethereum.github.io/yellowpaper/paper.pdf - Solidity 0.5.0 breaking changes (
callcodeno longer allowed), the contracts documentation onview/pureusing STATICCALL, a library'sviewfunctions using DELEGATECALL, the library call guard, and the note thatsend()/transfer()are no longer recommended: https://docs.soliditylang.org/en/v0.5.0/050-breaking-changes.html , https://docs.soliditylang.org/en/latest/contracts.html - ethereum.org EVM opcode reference and the wolflo/evm-opcodes dynamic gas table, value transfer 9000, new account 25000, the 2300 stipend: https://ethereum.org/en/developers/docs/evm/opcodes/ , https://github.com/wolflo/evm-opcodes/blob/main/gas.md