Solidity Gas Optimization: Techniques and Measurement
Review storage layout, data location, custom errors, and compiler settings, then compare gas measurements before and after each change.
Gas optimization starts with measuring transaction costs for representative calls. Storage access, data location, error encoding, and compiler settings are useful areas to investigate.
Why Gas Optimization Matters in Solidity
EVM gas costs depend on the opcode and execution context, including whether a storage slot is warm or cold. Profile representative transactions instead of relying on a universal ranking of storage, computation, and memory.
Compare changes against the EVM cost model and the compiler output for the version you deploy.
Solidity Gas Optimization Techniques
Pack Storage Variables
The EVM reads and writes storage in 32-byte (256-bit) slots. If you declare variables that together fit in one slot, the compiler can pack them, reducing the number of SLOAD and SSTORE operations:
// Inefficient — three separate slots
uint256 a;
uint128 b;
uint128 c;
// Efficient — b and c share one slot
uint128 b;
uint128 c;
uint256 a;
For new storage layouts, assess whether packing related values reduces gas in the operations that use them. Do not reorder an upgradeable contract's existing storage without a compatible migration plan.
Cache Storage Reads in Memory
Every access to a state variable is an SLOAD. Cache values in local variables before loops or repeated use:
// Reads storage on every iteration — expensive
for (uint256 i = 0; i < users.length; i++) { ... }
// Cache once
uint256 len = users.length;
for (uint256 i = 0; i < len; i++) { ... }
On a warm storage slot, SLOAD costs 100 gas. MLOAD (memory read) costs 3 gas. This difference compounds significantly in loops.
Use calldata Instead of memory for Read-Only Parameters
When an external function receives an array or bytes value that it does not modify, declare the parameter as calldata rather than memory:
// memory copies the data — more expensive
function process(uint256[] memory values) external { ... }
// calldata reads in place — cheaper
function process(uint256[] calldata values) external { ... }
calldata parameters are never copied — the function reads directly from the transaction input data. For large arrays, this is a significant saving.
Use Custom Errors Instead of String Reverts
String error messages are stored in bytecode and cost gas on revert. Custom errors are encoded as a 4-byte selector:
// Expensive — string stored in bytecode
require(amount > 0, "Amount must be positive");
// Cheap — 4-byte selector only
error InvalidAmount(uint256 provided);
if (amount == 0) revert InvalidAmount(amount);
Custom errors provide a selector and ABI-encoded arguments when present. Compare their measured bytecode and execution cost with the revert messages they replace.
Use immutable and constant for Values Set at Deploy Time
constant variables are inlined into bytecode — no storage, no read cost:
uint256 public constant MAX_SUPPLY = 10_000;
immutable variables are set once in the constructor and then inlined similarly — useful for addresses and values you only know at deploy time:
address public immutable token;
constructor(address _token) { token = _token; }
If a value never changes after deployment, it should be immutable or constant. Using regular state variables for these is a common and unnecessary gas cost.
Use unchecked for Safe Arithmetic
Solidity 0.8.x checks every arithmetic operation for overflow and underflow by default. This is the right default. But in contexts where overflow is provably impossible — such as a loop counter that can never exceed the length of a bounded array — the check is unnecessary:
for (uint256 i = 0; i < len; ) {
// do work
unchecked { ++i; } // safe: i < len guarantees no overflow
}
Do not assume ++i is cheaper than i++ when the expression's value is unused. Compiler version and optimizer settings affect the result. Modern Solidity also optimizes eligible loop overflow checks. Solidity optimizer documentation.
Enable and Configure the Optimizer
The Solidity compiler optimizer reduces bytecode size and execution cost. In your Hardhat or Foundry config:
// foundry.toml
[profile.default]
optimizer = true
optimizer_runs = 200
The optimizer_runs parameter tells the optimizer how many times you expect each function to run over the contract's lifetime. A higher value optimizes for runtime cost (larger bytecode, cheaper execution). A lower value optimizes for deployment cost. For most consumer-facing contracts, 200 is a reasonable default.
Minimize On-Chain Storage for Off-Chain Data
If data is only consumed by off-chain systems, emit events instead of writing to storage. Events cost roughly 375 base gas plus a small per-byte cost. A cold SSTORE costs 20,000 gas. For state that an indexer or frontend will read but that no contract logic depends on, events are the correct mechanism.
Batch Operations Where Possible
A transaction has a fixed base cost of 21,000 gas. If users can send one transaction that does ten operations instead of ten separate transactions, they save on base cost. Design batch functions for high-frequency user operations.
Measuring Before and After
Do not optimize without measuring. Foundry's gas snapshot creates a baseline:
forge snapshot
Make your changes, then run forge snapshot --diff to see exactly which functions improved and by how much. Never ship an optimization that you have not measured.
Keep gas snapshots in CI so later changes can be compared with a recorded baseline.