Gas Optimization in Solidity: Advanced Techniques That Actually Work
Master gas optimization in Solidity with practical techniques for storage packing, function optimization, and EVM-level tricks. Includes before/after benchmarks.
Gas costs can make or break a smart contract's usability. A poorly optimized contract might cost users $50 for a simple operation during high network activity. In this guide, I'll share battle-tested optimization techniques with real benchmarks.
Understanding Gas Costs
Before optimizing, you need to understand where gas goes:
| Operation | Gas Cost | Notes |
|---|---|---|
| SSTORE (new slot) | 20,000 | Most expensive operation |
| SSTORE (modify) | 5,000 | Changing existing value |
| SLOAD | 2,100 | Reading storage |
| Memory expansion | Variable | Quadratic cost growth |
| CALL | 2,600+ | External calls are costly |
"The cheapest transaction is the one you don't make. Design your contracts to minimize state changes."
Storage Optimization
Storage is by far the most expensive operation. Let's optimize it.
Variable Packing
The EVM reads storage in 32-byte slots. Pack variables to use fewer slots:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
// BAD: Uses 3 storage slots (60,000 gas for initialization)
contract Unpacked {
uint256 a; // Slot 0
uint8 b; // Slot 1 (wastes 31 bytes)
uint256 c; // Slot 2
uint8 d; // Slot 3 (wastes 31 bytes)
}
// GOOD: Uses 2 storage slots (40,000 gas for initialization)
contract Packed {
uint256 a; // Slot 0
uint256 c; // Slot 1
uint8 b; // Slot 2 (packed)
uint8 d; // Slot 2 (packed with b)
}Savings: ~33% on storage initialization
Mappings vs Arrays
For large datasets, mappings are almost always cheaper:
// BAD: Array iteration is O(n) gas
contract ArrayBased {
address[] public users;
function userExists(address user) public view returns (bool) {
for (uint i = 0; i < users.length; i++) {
if (users[i] == user) return true;
}
return false;
}
}
// GOOD: Mapping lookup is O(1) gas
contract MappingBased {
mapping(address => bool) public isUser;
address[] public userList; // Keep array only if enumeration needed
function userExists(address user) public view returns (bool) {
return isUser[user]; // ~2,100 gas regardless of size
}
}Delete for Refunds
Clearing storage gives you a gas refund:
contract StorageRefund {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "No balance");
// Delete before transfer for gas refund
delete balances[msg.sender]; // Refunds ~4,800 gas
payable(msg.sender).transfer(amount);
}
}Function Optimization
Calldata vs Memory
For read-only array parameters, calldata is significantly cheaper:
// BAD: Copies array to memory (~600 gas per element)
function processItems(uint256[] memory items) external {
for (uint i = 0; i < items.length; i++) {
// Process item
}
}
// GOOD: Reads directly from calldata (~0 extra gas)
function processItems(uint256[] calldata items) external {
for (uint i = 0; i < items.length; i++) {
// Process item
}
}Savings: 60+ gas per array element
Short-Circuit Conditions
Order conditions by likelihood and cost:
// BAD: Expensive check first
function claim(uint256 tokenId) external {
require(
verifyMerkleProof(tokenId), // Expensive ~5,000 gas
"Invalid proof"
);
require(msg.sender == owner, "Not owner"); // Cheap ~200 gas
}
// GOOD: Cheap checks first
function claim(uint256 tokenId) external {
require(msg.sender == owner, "Not owner"); // Fails fast if not owner
require(verifyMerkleProof(tokenId), "Invalid proof");
}Unchecked Math
When overflow is impossible, skip the checks:
contract LoopOptimization {
// BAD: Overflow check on every iteration
function sum(uint256[] calldata nums) external pure returns (uint256) {
uint256 total = 0;
for (uint256 i = 0; i < nums.length; i++) {
total += nums[i];
}
return total;
}
// GOOD: Unchecked increment saves ~80 gas per iteration
function sumOptimized(uint256[] calldata nums) external pure returns (uint256) {
uint256 total = 0;
uint256 len = nums.length;
for (uint256 i = 0; i < len;) {
total += nums[i];
unchecked { ++i; }
}
return total;
}
}Savings: ~80 gas per loop iteration
Loop Optimization
Loops are gas hotspots. Every optimization matters.
Cache Array Length
// BAD: Reads length from storage on every iteration
function processUsers() external {
for (uint i = 0; i < users.length; i++) { // SLOAD each time
// Process user
}
}
// GOOD: Cache length in memory
function processUsersOptimized() external {
uint256 len = users.length; // Single SLOAD
for (uint i = 0; i < len;) {
// Process user
unchecked { ++i; }
}
}Avoid Storage Reads in Loops
contract LoopStorage {
uint256 public multiplier = 100;
uint256[] public values;
// BAD: Reads storage variable in every iteration
function calculate() external view returns (uint256) {
uint256 total = 0;
for (uint i = 0; i < values.length; i++) {
total += values[i] * multiplier; // SLOAD for multiplier
}
return total;
}
// GOOD: Cache storage variable
function calculateOptimized() external view returns (uint256) {
uint256 total = 0;
uint256 _multiplier = multiplier; // Single SLOAD
uint256 len = values.length;
for (uint i = 0; i < len;) {
total += values[i] * _multiplier;
unchecked { ++i; }
}
return total;
}
}Savings: ~2,100 gas per avoided SLOAD
Advanced Techniques
Custom Errors vs Require Strings
Custom errors are much cheaper than string messages:
// BAD: String stored in bytecode and runtime
contract OldStyle {
function withdraw(uint256 amount) external {
require(
amount <= balance,
"InsufficientBalance: requested amount exceeds available balance"
);
}
}
// GOOD: Custom error with parameters
error InsufficientBalance(uint256 requested, uint256 available);
contract NewStyle {
function withdraw(uint256 amount) external {
if (amount > balance) {
revert InsufficientBalance(amount, balance);
}
}
}Savings: ~200 gas + deployment cost savings
Immutable vs Constant
Both save gas, but differently:
contract Constants {
// Constant: Value known at compile time
// Embedded directly in bytecode (0 gas to read)
uint256 public constant MAX_SUPPLY = 10000;
// Immutable: Set once in constructor
// Stored in bytecode after deployment (~3 gas to read)
address public immutable owner;
uint256 public immutable deployTime;
constructor() {
owner = msg.sender;
deployTime = block.timestamp;
}
}Inline Assembly Basics
For critical paths, assembly can help:
contract AssemblyOptimization {
// Standard Solidity
function isContract(address account) external view returns (bool) {
return account.code.length > 0;
}
// Assembly version (~100 gas cheaper)
function isContractOptimized(address account) external view returns (bool result) {
assembly {
result := gt(extcodesize(account), 0)
}
}
}Use assembly sparingly - it's harder to audit and can introduce bugs.
Benchmarking Your Optimizations
Always measure your changes. Here's a simple test setup:
// test/GasTest.t.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
import "forge-std/Test.sol";
import "../src/MyContract.sol";
contract GasTest is Test {
MyContract public target;
function setUp() public {
target = new MyContract();
}
function testGasBaseline() public {
uint256 gasBefore = gasleft();
target.unoptimizedFunction();
uint256 gasUsed = gasBefore - gasleft();
console.log("Unoptimized gas:", gasUsed);
}
function testGasOptimized() public {
uint256 gasBefore = gasleft();
target.optimizedFunction();
uint256 gasUsed = gasBefore - gasleft();
console.log("Optimized gas:", gasUsed);
}
}Run with Foundry:
forge test --match-contract GasTest -vvv --gas-reportOptimization Checklist
Before deploying, run through this checklist:
Storage
- Variables packed into 32-byte slots
- Mappings used instead of arrays for lookups
- Unused storage deleted for refunds
- Struct fields ordered by size (largest to smallest)
Functions
-
calldataused for read-only arrays/structs - Conditions ordered by cost and likelihood
-
uncheckedblocks for safe math operations - External functions preferred over public when possible
Loops
- Array lengths cached before loops
- Storage variables cached in memory
- Loop counters use
uncheckedincrement - No redundant storage reads
General
- Custom errors instead of require strings
-
constantandimmutablefor fixed values - Events emit indexed params for cheap filtering
- Batch operations where possible
Real-World Impact
Here's a before/after comparison from a real project:
| Operation | Before | After | Savings |
|---|---|---|---|
| Token transfer | 65,000 | 48,000 | 26% |
| Batch mint (10) | 320,000 | 185,000 | 42% |
| Claim rewards | 95,000 | 52,000 | 45% |
These savings compound quickly. At $50 gas prices, saving 45% on a common operation saves users real money.
Conclusion
Gas optimization is part art, part science. The techniques here will cover 90% of real-world scenarios. Remember:
- Measure first - Don't optimize blindly
- Storage is king - Focus optimization efforts there
- Readability matters - Don't sacrifice clarity for minor gains
- Test thoroughly - Optimizations can introduce bugs
The best optimization is often architectural. Before micro-optimizing, ask if there's a simpler design that requires fewer on-chain operations.
Resources
- EVM Opcodes Reference
- Foundry Book (Testing)
- Solidity Gas Optimizations (GitHub Awesome List)
- EIP-1559 Gas Mechanics
Nawab Khairuzzaman
Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.