Gas Optimization of Smart Contracts
Gas is not an abstraction, it is money. At 30 gwei gas price and ETH at $3000, one transaction with 200,000 gas costs $18. Multiply by thousands of users daily — difference between optimized and unoptimized contract measures in hundreds of thousands dollars per year. On L2 gas is cheaper, but calldata still expensive and optimization remains important.
Gas optimization is not "make code prettier." It is knowledge of EVM opcodes and their costs, data storage patterns, and ability to read Foundry gas reports. Let's cover key techniques.
Storage: Main Expense Source
SSTORE and SLOAD Cost
Writing to storage (SSTORE) — one of most expensive opcodes: 20,000 gas for write to new slot, 5,000 gas for change to existing (cold), 100 gas for repeated change in same transaction (warm, EIP-2929).
Reading (SLOAD) — 2,100 gas cold, 100 gas warm. If reading same storage slot multiple times in function — cache in memory variable:
// Bad: two SLOADs
function bad() external view returns (uint256) {
return balances[msg.sender] + balances[msg.sender] / 100;
}
// Good: one SLOAD
function good() external view returns (uint256) {
uint256 balance = balances[msg.sender]; // one SLOAD
return balance + balance / 100;
}
Storage Packing
EVM slot — 32 bytes. If you have multiple variables smaller than 32 bytes — Solidity will pack them into one slot if declared nearby. This reduces SLOAD/SSTORE count.
// Bad: three separate slots
uint256 a; // slot 0
uint128 b; // slot 1 (inefficient — takes whole slot)
uint128 c; // slot 2 (inefficient)
// Good: b and c packed into one slot
uint256 a; // slot 0
uint128 b; // slot 1 (first 16 bytes)
uint128 c; // slot 1 (next 16 bytes)
For struct this is especially important: field order inside struct affects slot count. Group small types together.
Mapping vs Array
Mapping cheaper for arbitrary access: O(1) and one SLOAD. Array with search — O(N) and N SLOADs. If you need "is X in list" — use mapping(address => bool), not array.
Iterating mapping on-chain impossible natively (no way to get all keys). If iteration needed — EnumerableSet from OpenZeppelin (stores both: mapping for O(1) lookup and array for iteration).
Calldata and Functions
Calldata vs Memory
Function parameters marked calldata not copied to memory — read directly from calldata. For arrays and strings this significant saving:
// memory: copies whole array — expensive
function processMemory(uint256[] memory data) external { ... }
// calldata: no copying — cheaper
function processCalldata(uint256[] calldata data) external { ... }
Difference grows with data size. For large array — savings hundreds of thousands gas.
Custom Errors vs Require Strings
Before Solidity 0.8.4 errors passed as string, encoded in calldata and stored in bytecode. Custom errors — cheaper both in deploy and revert:
// Old way: expensive
require(amount > 0, "Amount must be positive");
// Custom error: cheaper
error InvalidAmount(uint256 amount);
if (amount == 0) revert InvalidAmount(amount);
Custom error saves ~200-500 gas per revert, and reduces bytecode size (fewer string literals).
Short Functions and Inlining
Each internal function call — additional overhead (JUMP opcodes, stack management). Solidity compiler with --via-ir (Yul IR) better optimizes inlining small functions. Enabling viaIR: true in foundry.toml/hardhat.config can reduce gas 5-15% without code changes.
Optimization Patterns
Packed Structs for Batch Operations
Processing array of objects, data structure affects storage slots per item:
| Structure | Slots per item | Gas per item |
|---|---|---|
| Three uint256 fields | 3 slots | ~60,000 gas write |
| uint128 + uint64 + uint64 | 1 slot | ~20,000 gas write |
| Bitmap flags | 1 slot / 256 items | ~78 gas per flag |
Bitmap for boolean flags: uint256 flags stores 256 flags in one slot. Operation flags |= (1 << bitIndex) — 100 gas instead of 20,000 for separate mapping(uint => bool).
Lazy Initialization
Don't initialize variables to default value — that's gas waste:
// Unnecessary SSTORE with zero (Solidity does this)
uint256 public counter = 0; // not needed
// Just
uint256 public counter;
Unchecked Arithmetic
Solidity 0.8+ added overflow/underflow checks to each arithmetic operation (+100-200 gas). If you're sure overflow impossible — use unchecked:
// Standard loop with checked arithmetic
for (uint256 i = 0; i < arr.length; i++) { ... }
// Optimized with unchecked increment
for (uint256 i = 0; i < arr.length; ) {
// ... logic
unchecked { ++i; } // prefix ++ also cheaper than postfix
}
Savings on typical 100-element loop — 5,000-10,000 gas.
Measurement Tools
Foundry gas snapshots — forge snapshot creates .gas-snapshot file with gas usage per test. forge snapshot --diff shows change after fixes. Essential for iterative optimization.
forge test --gas-report — table with average/min/max gas per function.
ETH Gas Station / Tenderly — transaction simulation with opcode breakdown. Useful to understand where exactly gas spent.
Audit and Optimization Process
- Baseline measurement:
forge snapshotbefore changes - Profiling: identify most expensive functions
- Storage analysis: check struct packing, unnecessary SLOADs
- Apply optimizations: by priority (storage → calldata → arithmetic)
- Verification:
forge snapshot --diff, verify functionality not broken
Typical result: 20-40% gas reduction for unoptimized contract, 10-20% for already "reasonable" code. For DeFi with high transaction load this direct user savings.
Timeline: audit + report + optimization implementation — 2-4 weeks depending on codebase size.







