Most gas in a typical contract is spent on storage, not computation. Arithmetic opcodes cost a few gas each; writing a fresh storage slot costs tens of thousands. So the optimizations that matter are the ones that touch storage less, followed by those that avoid copying data around. Micro-tricks come last, and only after you've measured.
Know what's expensive
Since the Berlin and London upgrades (EIP-2929, EIP-2200, EIP-3529), the storage costs you should keep in mind are:
| Operation | Gas |
|---|---|
SSTORE zero to non-zero | 20,000 (plus 2,100 if the slot is cold) |
SSTORE non-zero to a different non-zero | 2,900 (plus 2,100 if cold) |
SLOAD first access in a transaction (cold) | 2,100 |
SLOAD again (warm) | 100 |
MLOAD / MSTORE, ADD, MUL | 3 to 5 |
| Each non-zero calldata byte | 16 (4 for a zero byte) |
Since EIP-7623 (Pectra), transactions that are mostly calldata pay a higher floor price per byte. Clearing a slot back to zero gives a partial refund, capped at a fifth of the transaction's gas. Everything below follows from this table.
1. Measure first
Guessing wastes time, and the optimizer already does a lot. Get numbers per function:
forge test --gas-report # per-function min/avg/max
forge snapshot # writes .gas-snapshot; commit it
forge snapshot --diff # compare against the committed snapshot
In Hardhat, use hardhat-gas-reporter. Optimize the functions your users call most, not the admin function called twice a year.
2. Pack storage variables
Each storage slot holds 32 bytes. Solidity packs consecutive variables that fit together into one slot, in the order you declare them:
// 3 slots: the uint256 in the middle forces a new slot on each side
struct Bad {
uint128 amount;
uint256 id;
uint128 deadline;
}
// 2 slots: the two uint128s share one slot
struct Good {
uint128 amount;
uint128 deadline;
uint256 id;
}
Packing pays off when the packed fields are read or written together, since one SLOAD returns them all. Use types that still fit your data safely: uint64 holds any realistic timestamp, uint128 is plenty for most token amounts, and an address (20 bytes) packs with a uint96 or a few bools.
3. Read storage once, keep it in a local variable
Every read of a state variable is at least a warm SLOAD (100 gas), versus 3 for a local variable. In loops this adds up:
// Before: reads rewardRate and users.length from storage on every iteration
for (uint256 i = 0; i < users.length; i++) {
rewards[users[i]] += rewardRate;
}
// After: one read of each, outside the loop
uint256 rate = rewardRate;
address[] memory list = users;
uint256 len = list.length;
for (uint256 i = 0; i < len; ++i) {
rewards[list[i]] += rate;
}
The same applies to writes: accumulate in a local variable and write the result once at the end, instead of updating a state variable every iteration.
4. Use constant and immutable
Values that never change shouldn't live in storage at all:
uint256 public constant MAX_SUPPLY = 10_000; // inlined at compile time
address public immutable treasury; // set in constructor, stored in bytecode
constructor(address treasury_) {
treasury = treasury_;
}
Reading either costs about as much as a PUSH, instead of 2,100 for a cold SLOAD. (For upgradeable contracts, immutables live in the implementation's bytecode, so use them carefully there.)
5. calldata for external array and struct parameters
An external function's memory parameter copies the whole argument from calldata into memory. calldata reads it in place:
function sum(uint256[] calldata values) external pure returns (uint256 total) {
for (uint256 i = 0; i < values.length; ++i) {
total += values[i];
}
}
Use memory only if you need to modify the array.
6. Custom errors instead of revert strings
Revert strings are stored in the bytecode and ABI-encoded at runtime. Custom errors use a 4-byte selector, make deployment cheaper, and can carry useful data:
error NotOwner(address caller);
function withdraw() external {
if (msg.sender != owner) revert NotOwner(msg.sender);
// ...
}
7. Don't store what you only read off-chain
If data is only needed by your frontend or indexer, emit an event instead of writing it to storage. A log costs a fraction of an SSTORE, and indexers (The Graph, your own backend) can rebuild the history. Contracts can't read events, so this only works for data no contract needs.
8. Avoid unbounded loops over storage
A loop over an array that grows with usage gets more expensive over time and will eventually exceed the block gas limit, locking the function forever. Prefer mappings for lookups, let users claim their own entries (pull rather than push), and paginate when you must iterate.
9. Compiler settings
# foundry.toml
[profile.default]
optimizer = true
optimizer_runs = 200
via_ir = true
optimizer_runs is a trade-off: a higher value optimizes for cheaper calls at the cost of larger bytecode and more expensive deployment. Set it high for contracts called very often. via_ir enables the IR-based pipeline, which often produces cheaper code but compiles more slowly. Measure both settings with forge snapshot rather than assuming.
Smaller wins, and what's no longer worth it
- Loop increments: since Solidity 0.8.22 the compiler removes the overflow check on simple
forloop counters, so wrapping++iinuncheckedusually doesn't help any more. uncheckedmath elsewhere saves a little, but only use it where overflow is provably impossible.- Bitmaps (
mapping(uint256 => uint256)with one bit per flag) instead ofmapping(uint256 => bool)for large sets of booleans, e.g. airdrop claims. - Transient storage (EIP-1153,
TSTORE/TLOADat 100 gas) for values that only need to last one transaction, like reentrancy locks. - On rollups, a large part of the fee pays for publishing transaction data to L1. Smaller calldata can matter more there than on Ethereum mainnet.
Don't use assembly to shave a few gas off a function until you've done everything above. It removes safety checks and makes audits harder.
Checklist
- Measure with
forge test --gas-reportand commit aforge snapshot. - Reduce
SSTOREs: pack related fields, write once, emit events for off-chain data. - Cache storage reads in local variables, especially in loops.
- Use
constant,immutable,calldataand custom errors. - Remove unbounded loops over storage.
- Tune
optimizer_runsand tryvia_ir, and verify with measurements.
Get the weekly commit
New blockchain deep dives every week.