There are hundreds of gas optimization tips floating around Solidity Twitter. Most of them save 20 gas per call. That is 0.04 cents at 30 gwei on Ethereum mainnet. Nobody cares. The tips that matter save thousands of gas per transaction because they change how your contract interacts with storage, memory, and calldata at a structural level.
This is a list of the patterns that moved the needle on contracts we shipped in the last year. Each one includes real gas numbers from Foundry benchmarks.
Storage layout is where the money is
A cold SLOAD costs 2100 gas. A cold SSTORE costs 22,100 gas for a zero to nonzero write. Everything else in the EVM is cheap by comparison. If you want to cut gas costs, start with how many storage slots you touch per transaction.
Pack your structs
The EVM reads and writes storage in 32 byte slots. If your struct fields fit in a single slot, the compiler will pack them together and you pay for one SLOAD instead of two or three. But the compiler only packs fields that are adjacent in the struct definition.
// Bad: 3 storage slots (96 bytes)
struct Order {
uint256 amount; // slot 0 (32 bytes)
address maker; // slot 1 (20 bytes)
uint256 expiry; // slot 2 (32 bytes)
bool active; // slot 3 (1 byte, wastes 31 bytes)
}
// Good: 2 storage slots (64 bytes)
struct Order {
uint256 amount; // slot 0 (32 bytes)
uint256 expiry; // slot 1 (32 bytes)
address maker; // slot 2 (20 bytes) --|
bool active; // slot 2 (1 byte) --| packed together
}
On a contract that reads an Order struct once per transaction, this saves 2100 gas. On a contract that reads it in a loop over 10 orders, that is 21,000 gas. Real money.
We had a staking contract where reordering three struct fields dropped the average claim transaction from 87,400 gas to 62,100 gas. That is a 29% reduction from changing six lines of code.
Use mappings over arrays when you do not need iteration
Arrays in storage are expensive. Reading array.length is one SLOAD. Reading array[i] is another SLOAD after computing the slot offset with keccak256. If you are using an array as a lookup table and never iterate over it, a mapping does the same job with fewer storage reads.
// Expensive: 2 SLOADs to check if user exists
address[] public users;
mapping(address => uint256) public userIndex;
function exists(address user) public view returns (bool) {
return userIndex[user] != 0; // still need the mapping anyway
}
// Cheaper: 1 SLOAD
mapping(address => bool) public isUser;
function exists(address user) public view returns (bool) {
return isUser[user];
}
The savings depend on your access pattern. If you genuinely need to enumerate all users on chain, you need the array. But nine times out of ten, enumeration can happen off chain by indexing events.
Calldata over memory for read only parameters
When a function receives a dynamic type (bytes, string, arrays) as a parameter, you choose between memory and calldata. Using calldata avoids copying the data into memory, which saves both the memory expansion cost and the copy opcodes.
// Before: copies entire array into memory
function processOrders(Order[] memory orders) external {
for (uint i = 0; i < orders.length; i++) {
_settle(orders[i]);
}
}
// Gas for 10 orders: 48,200
// After: reads directly from calldata
function processOrders(Order[] calldata orders) external {
for (uint i = 0; i < orders.length; i++) {
_settle(orders[i]);
}
}
// Gas for 10 orders: 39,800
That is 8,400 gas saved, or about 840 per order. The constraint is that calldata is read only. If your function modifies the array, you need memory. But most batch processing functions just read the input and write results to storage.
Short circuit your requires
Put your cheapest checks first. Every require that passes costs gas for the comparison. Every require that fails reverts and refunds the remaining gas. So you want the most likely failure to happen as early and cheaply as possible.
// Bad: expensive SLOAD happens even when msg.value is wrong
function deposit(uint256 poolId) external payable {
Pool storage pool = pools[poolId]; // cold SLOAD: 2100 gas
require(pool.active, "inactive");
require(msg.value >= pool.minDeposit, "too low");
require(msg.value > 0, "zero"); // cheapest check last
}
// Good: free check first, then progressively more expensive
function deposit(uint256 poolId) external payable {
require(msg.value > 0, "zero"); // free: reads msg.value
Pool storage pool = pools[poolId]; // cold SLOAD: 2100 gas
require(pool.active, "inactive");
require(msg.value >= pool.minDeposit, "too low");
}
This saves 2100 gas on the zero value revert path. That path might be hit by bots, fat fingered users, or your own frontend bugs. It adds up.
Custom errors over require strings
This one is well known but still underused in production code. Custom errors introduced in Solidity 0.8.4 encode the error as a 4 byte selector instead of an ABI encoded string.
// Before: stores and returns "Insufficient balance" string
require(balance >= amount, "Insufficient balance");
// Revert cost: ~2,400 gas (string ABI encoding)
// After: returns 4 byte selector
error InsufficientBalance(uint256 available, uint256 required);
if (balance < amount) revert InsufficientBalance(balance, amount);
// Revert cost: ~500 gas
The savings are about 1,900 gas per revert. More importantly, custom errors shrink your deployed bytecode because the compiler does not need to store the string literals. On a contract with 40 require statements, switching to custom errors saved us 2.8 KB of bytecode. That matters when you are near the 24,576 byte contract size limit.
Unchecked arithmetic where overflow is impossible
Solidity 0.8+ adds overflow and underflow checks to every arithmetic operation. Each checked add costs about 20 gas more than an unchecked one. That is nothing on a single operation but compounds in tight loops.
// Before: checked increment in loop
for (uint256 i = 0; i < length; i++) { // overflow check on i++
totals[i] = balances[i] + rewards[i]; // overflow check
}
// Gas for 100 iterations: 34,600
// After: unchecked where safe
for (uint256 i = 0; i < length; ) {
totals[i] = balances[i] + rewards[i]; // keep this checked
unchecked { i++; } // i can't overflow: bounded by length
}
// Gas for 100 iterations: 32,400
The 2,200 gas saving comes entirely from the unchecked loop increment. We did not remove the check on the balance addition because that one could actually overflow with adversarial inputs. Be specific about what you uncheck.
Events are cheaper than storage for data you only read off chain
If you are writing data to storage just so your frontend can read it, stop. Events cost roughly 375 gas for the first topic plus 375 per indexed parameter plus 8 gas per byte of unindexed data. Storing the same data in a mapping costs at minimum 22,100 gas for a fresh slot.
// Expensive: 22,100+ gas per write
mapping(uint256 => TradeRecord) public trades;
function recordTrade(uint256 id, address user, uint256 amount) internal {
trades[id] = TradeRecord(user, amount, block.timestamp);
}
// Cheap: ~1,500 gas per emission
event TradeRecorded(uint256 indexed id, address indexed user, uint256 amount, uint256 timestamp);
function recordTrade(uint256 id, address user, uint256 amount) internal {
emit TradeRecorded(id, user, amount, block.timestamp);
}
The tradeoff is that other contracts cannot read events. If you need on chain composability with that data, you need storage. If only your indexer reads it, use events.
When to stop optimizing
Every optimization makes your code harder to read. Struct packing requires comments explaining why fields are in a strange order. Unchecked blocks require reasoning about overflow safety. Custom errors require maintaining a separate error interface.
Here is our rule. Optimize if the function is called more than 1,000 times per month and the optimization saves more than 2,000 gas per call. Below that threshold, the code clarity cost exceeds the gas savings. At 30 gwei and $3,000 ETH, saving 2,000 gas saves $0.18 per call. At 1,000 calls per month that is $180. Worth doing. Saving 50 gas per call at 100 calls per month is $0.45 total. Not worth the code review argument.
There is one exception. If you are near the contract size limit, optimize bytecode size regardless of gas savings. A contract that does not deploy is worse than a contract that costs a little extra to call.
Summary of real numbers
| Optimization | Before | After | Saved |
|---|---|---|---|
| Struct packing (3 slots to 2) | 87,400 | 62,100 | 25,300 |
| Calldata vs memory (10 items) | 48,200 | 39,800 | 8,400 |
| Short circuit requires | 2,400 | 300 | 2,100 |
| Custom errors (per revert) | 2,400 | 500 | 1,900 |
| Unchecked loop (100 iters) | 34,600 | 32,400 | 2,200 |
| Events vs storage write | 22,100 | 1,500 | 20,600 |
Storage layout and the events vs storage decision dominate everything else. If you only have time for two optimizations, do those two. Leave the micro optimizations for the next audit cycle when someone has time to argue about whether != 0 is cheaper than > 0. (It saves 3 gas. Do not bother.)