Solidity Deep Dive: Memory, Storage, Calldata, and Gas Optimization Patterns
A thorough exploration of Solidity's data location model and advanced gas optimization patterns to make your contracts faster and cheaper to use.
Solidity's Data Location Model
Solidity has three data locations: storage, memory, and calldata. Understanding the gas cost and semantics of each is fundamental to writing efficient Solidity.
Storage is the most expensive. Reading a storage slot for the first time in a transaction costs 2,100 gas (cold read). Reading it again costs 100 gas (warm read, after EIP-2929). Writing a storage slot from zero to non-zero costs 20,000 gas. Writing a non-zero slot to a different non-zero value costs 2,900 gas. Setting a slot from non-zero to zero costs 2,900 gas but provides a 4,800 gas refund (maximum refund is 20% of total gas used as of EIP-3529).
Memory is temporary — it exists only for the duration of a function call and is automatically freed. Memory costs 3 gas per word (32 bytes) for the first 724 words, then increases quadratically. For most practical use cases (function-local arrays up to a few KB), memory costs are negligible. For very large memory arrays (megabytes), quadratic memory expansion costs become significant.
Calldata is immutable read-only data passed to external functions. Calldata is the cheapest data location for function inputs: non-zero bytes cost 16 gas each, zero bytes cost 4 gas each. Calldata parameters are not copied — they reference the incoming transaction data directly.
Storage Optimization Patterns
Storage is where most gas optimization gains are found.
Variable packing: the Ethereum storage model allocates 32-byte slots. Multiple smaller variables can share a slot. Declaring uint128 a; uint128 b; uses one slot if they are adjacent. Declaring uint256 a; uint128 b; uses two slots because the uint256 fills the first slot. Always group small variables together and order larger variables last.
Struct packing: apply the same principle to struct fields. Order fields from smallest to largest within each slot group. uint8, uint8, uint8, uint8 (packed into 4 bytes of one slot) is four times cheaper to store than four separate uint256 variables.
Avoid SSTORE when possible: cache storage reads in memory, especially in loops. If you read a storage variable N times, you pay one cold read (2,100 gas) and N-1 warm reads (100 gas each) if you re-read from storage. If you cache it in a local variable, you pay one cold storage read and N-1 cheap memory reads (3 gas).
Mapping vs array: mappings do not store length and have no iteration cost. Arrays store length in a separate slot and cost gas proportional to iteration. Use mappings when random access is needed; use arrays only when ordered iteration is required.
Short-circuit storage: use immutable for values set at construction and never changed. Immutable variables are stored in contract bytecode and cost only calldata gas to read. Similarly, use constant for compile-time constants.
Memory vs Calldata: When to Use Each
For external function parameters that are not modified, prefer calldata over memory. Calldata avoids copying the data to memory:
function processData(bytes calldata data) external — the data is read directly from the transaction input.
function processData(bytes memory data) external — the data is copied from calldata to memory, costing extra gas.
For arrays and structs passed to external functions, calldata is almost always cheaper when the data is only read, not modified.
When to use memory:
When you need to modify the parameter inside the function.
When the function is internal or public (calldata is only for external).
When building a new array dynamically (memory arrays can be resized during construction; calldata cannot).
Returning large structs: returning a large struct from a function copies it to memory and then copies it again for the return. For very large structs, consider returning only the fields the caller needs, or returning a storage pointer (only works for internal functions).
Memory vs storage for intermediate computation: if you are doing complex computations on a struct from storage, copy the entire struct to a memory variable first, operate on the memory copy, and write back only the changed fields. This batches storage writes and reduces warm storage reads.
Advanced Gas Optimization Techniques
Beyond data location, several advanced patterns reduce gas consumption significantly.
Custom errors vs revert strings: require(condition, "Error message") stores the error string in bytecode and costs gas for string encoding in reverts. Custom errors (error InsufficientBalance(uint256 required, uint256 actual); revert InsufficientBalance(...)) use only the 4-byte selector and ABI-encoded parameters. For frequently reverted paths, custom errors save 30-50% of revert gas.
Unchecked arithmetic: Solidity 0.8.x adds overflow checks to every arithmetic operation. Inside an unchecked { } block, these checks are skipped. For loop counters and arithmetic where overflow is provably impossible (i < array.length — i cannot overflow), unchecked saves ~30 gas per operation.
Short-circuit evaluation: in boolean expressions, place cheap conditions before expensive ones. if (simpleCheck && expensiveCheck) — if simpleCheck is false, expensiveCheck is never evaluated.
Bitwise operations: bitwise shifts (<< and >>) are cheaper than multiplication and division by powers of two. value >> 1 is cheaper than value / 2.
Avoid zero-to-nonzero writes: initializing a storage slot from zero costs 20,000 gas. Re-using slots (writing non-zero to different non-zero) costs 2,900 gas. One pattern: instead of deleting and re-creating data, use a "version" or "generation" counter. If the stored version does not match the current, treat the data as deleted.
Event data instead of state: if data is only needed for off-chain queries (dashboards, history), emit it as an event rather than storing it. Events cost ~375 gas per word plus per-topic cost. Storage costs 20,000 gas per slot. For historical data, events are 50-100x cheaper.
Profiling and Measuring Gas Consumption
Optimization without measurement is guesswork. Foundry's gas profiling tools are the most useful for this.
forge test --gas-report: runs the test suite and prints a table showing the min, max, and average gas for every function call across all tests. This is the starting point for identifying hot paths.
Snapshot testing: forge snapshot creates a gas snapshot file. After optimization changes, run forge snapshot --diff to see exactly how gas changed for every test case.
cast estimate: estimate gas for a specific transaction before sending it. Useful for production monitoring.
Gas profiler in Foundry traces: forge test -vvvv prints full execution traces with gas costs at each call level. Use this to identify which specific operations inside a complex function consume the most gas.
Systematic optimization workflow:
1. Write tests first (you cannot optimize what you cannot measure).
2. Run forge test --gas-report to establish baseline.
3. Identify the highest-gas functions.
4. Profile with -vvvv to find expensive operations within those functions.
5. Apply targeted optimizations.
6. Re-run --gas-report and compare.
7. Verify all tests still pass.
Never optimize without a test suite. Gas optimization changes are subtle and can introduce bugs. Every optimization change should be followed by full test suite execution.