How do I cut gas costs in Solidity contracts? · Jumpstart Blockchain
How do I cut gas costs in Solidity contracts?
Storage dominates gas costs: pack variables, cache storage reads, use constant, immutable, calldata and custom errors, emit events for off-chain data, and measure every change with forge snapshot.
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:
MEV is profit from ordering transactions. A sandwich attack front-runs and back-runs a public swap, capped by its slippage. Use tight minimum outputs from fresh quotes and private RPCs.
// 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.
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 for loop counters, so wrapping ++i in unchecked usually doesn't help any more.
unchecked math elsewhere saves a little, but only use it where overflow is provably impossible.
Bitmaps (mapping(uint256 => uint256) with one bit per flag) instead of mapping(uint256 => bool) for large sets of booleans, e.g. airdrop claims.
Transient storage (EIP-1153, TSTORE/TLOAD at 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-report and commit a forge 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, calldata and custom errors.
Remove unbounded loops over storage.
Tune optimizer_runs and try via_ir, and verify with measurements.
Approve is safe; unlimited, forgotten allowances and unread permit signatures are not. Approve exact amounts, use permit with short deadlines, read every typed-data signature, and revoke old approvals.
It's usually a max fee below the base fee, a low tip, or an earlier stuck nonce. Speed up by resending with the same nonce and higher fees; cancel by sending 0 ETH to yourself with that nonce.