Why Gas Optimization Matters
Every operation on Ethereum costs gas, and users pay for that gas in ETH. A poorly optimized contract can cost users hundreds of dollars per transaction during high network activity. Gas optimization is both an economic necessity (lower costs attract more users) and a technical skill that demonstrates deep understanding of the EVM. The key is understanding what operations are expensive and finding ways to reduce or eliminate them.
EVM Gas Cost Reference
- SSTORE (new slot): 20,000 gas — writing to a new storage slot is the most expensive common operation
- SSTORE (update): 5,000 gas — updating an existing non-zero slot
- SSTORE (zero to zero): 2,900 gas with refund — clearing a slot gives a partial refund
- SLOAD: 2,100 gas (cold) / 100 gas (warm) — first read is expensive, subsequent reads are cheap
- MSTORE/MLOAD: 3 gas — memory is very cheap compared to storage
- CALLDATALOAD: 3 gas — reading function arguments is cheap
Storage Packing
The EVM uses 32-byte (256-bit) storage slots. If your variables are smaller than 32 bytes and declared consecutively, Solidity packs them into the same slot, saving gas on both reads and writes.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// BAD: Each variable gets its own 32-byte slot (3 slots = 3 SSTORE operations)
contract Unpacked {
uint256 public amount; // Slot 0 (32 bytes)
bool public isActive; // Slot 1 (only uses 1 byte, wastes 31)
address public owner; // Slot 2 (only uses 20 bytes, wastes 12)
}
// GOOD: Variables are packed into fewer slots (2 slots instead of 3)
contract Packed {
uint256 public amount; // Slot 0 (32 bytes — full slot)
address public owner; // Slot 1 (20 bytes)
bool public isActive; // Slot 1 (1 byte — packed with address!)
// Total: 2 slots instead of 3
}
// EVEN BETTER: Use smaller uint types when possible
contract OptimalPacking {
// All fit in ONE slot (20 + 8 + 4 = 32 bytes)
address public owner; // 20 bytes
uint64 public timestamp; // 8 bytes
uint32 public amount; // 4 bytes
}
Use Constants and Immutables
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract GasEfficient {
// constant — value embedded in bytecode, NO storage slot, NO SLOAD
uint256 public constant MAX_SUPPLY = 10000; // 0 gas to read
bytes32 public constant ADMIN_ROLE = keccak256("ADMIN");
// immutable — set once in constructor, stored in bytecode, NO SLOAD
address public immutable deployer; // 0 gas to read
uint256 public immutable deployTimestamp;
// Regular storage variable — costs 2,100 gas (cold) to read
uint256 public regularVariable;
constructor() {
deployer = msg.sender;
deployTimestamp = block.timestamp;
regularVariable = 42;
}
}
Efficient Loops and Caching
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract LoopOptimizations {
uint256[] public values;
mapping(address => uint256) public balances;
// BAD: Reads .length from storage on EVERY iteration
function sumBad() external view returns (uint256 total) {
for (uint256 i = 0; i < values.length; i++) {
total += values[i];
}
}
// GOOD: Cache length in memory, use unchecked increment
function sumGood() external view returns (uint256 total) {
uint256 len = values.length; // Cache storage read
for (uint256 i = 0; i < len; ) {
total += values[i];
unchecked { ++i; } // Save ~60 gas per iteration (no overflow check)
}
}
// Cache storage variable in memory for multiple reads
function processBalance(address user) external {
uint256 bal = balances[user]; // One SLOAD (2,100 gas)
// Use memory variable for all subsequent reads (3 gas each)
if (bal > 100) {
// ... use bal instead of balances[user]
}
if (bal > 50) {
// ... use bal again
}
}
}
Custom Errors and Short Revert Strings
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract ErrorOptimization {
// BAD: String messages stored in bytecode, expensive to deploy and revert
function badRevert(uint256 x) external pure {
require(x > 0, "Value must be greater than zero"); // ~200+ gas per character
}
// GOOD: Custom errors — much cheaper, typed, and can carry data
error ValueTooLow(uint256 provided, uint256 minimum);
function goodRevert(uint256 x) external pure {
if (x == 0) revert ValueTooLow(x, 1); // ~24 gas for the selector
}
}
Calldata over Memory
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract CalldataOptimization {
// BAD: memory copies the entire array from calldata to memory
function processBad(uint256[] memory data) external pure returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
// GOOD: calldata reads directly from transaction data (no copy)
function processGood(uint256[] calldata data) external pure returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < data.length; i++) {
sum += data[i];
}
return sum;
}
// Savings: ~60 gas per array element (no memory allocation/copy)
}
Mapping vs Array, and Batch Operations
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract BatchOperations {
mapping(address => uint256) public balances;
// BAD: Multiple separate transactions (21,000 base gas each!)
function singleTransfer(address to, uint256 amount) external {
balances[msg.sender] -= amount;
balances[to] += amount;
}
// GOOD: Batch multiple operations in one transaction
function batchTransfer(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length, "Length mismatch");
uint256 totalAmount = 0;
uint256 len = recipients.length;
for (uint256 i = 0; i < len; ) {
totalAmount += amounts[i];
balances[recipients[i]] += amounts[i];
unchecked { ++i; }
}
balances[msg.sender] -= totalAmount;
}
// Saves 21,000 gas per additional transfer (base transaction cost)
}
Gas Optimization Checklist
- Pack storage variables — order them by size to minimize slot usage
- Use constant/immutable — for values that never change after deployment
- Cache storage reads — read to memory once, use the cached value repeatedly
- Use calldata for inputs — avoid copying data to memory when you only need to read it
- Use custom errors — much cheaper than require with string messages
- Unchecked math — use unchecked blocks when overflow is impossible (e.g., loop counters)
- Batch operations — combine multiple operations into single transactions
- Use events for data — emit events instead of storing data that only needs to be read off-chain