Difficulty: Advanced
Gas optimization reduces computational and storage costs of contract execution. Every EVM operation has a gas cost paid in ETH. On Ethereum mainnet during high demand, optimization directly reduces user costs and makes protocols competitive. Understanding the EVM cost model - which operations are expensive vs cheap - is essential for efficient Solidity.
Storage is by far the most expensive EVM resource. Writing a new slot (SSTORE) costs 20,000 gas, modifying an existing slot costs 5,000 gas, reading (SLOAD) costs 2,100 gas cold or 100 gas warm. Storage packing is the primary optimization: the EVM uses 32-byte slots, so multiple variables smaller than 32 bytes can share a slot. Two uint128 variables use one slot instead of two, saving 20,000 gas on initial write. Solidity packs struct members in declaration order, so ordering matters.
Function parameters have different costs by data location. calldata is cheapest (read-only, no copy). memory creates a mutable copy (more expensive). storage refers to contract state (most expensive). For external functions that only read inputs, use calldata. Constants and immutables are embedded in bytecode at compile time, costing zero gas to read.
Batch operations amortize fixed costs across multiple operations. Instead of N transactions (each paying 21,000 base gas), one batch pays base gas once. For loops, use unchecked { ++i } to save ~60 gas per iteration. Short-circuit evaluation checks cheap conditions first. Custom errors are cheaper than string messages.
Other optimizations: bytes32 instead of string for fixed-length data, minimize external calls (2,600+ gas overhead each), use events instead of storage for off-chain data, precompute values off-chain. The Solidity optimizer handles some optimizations, but understanding costs lets you make architectural decisions the optimizer cannot.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// BAD: 6 storage slots
contract Unoptimized {
uint256 public amount; // Slot 0 (32 bytes)
bool public isActive; // Slot 1 (1 byte + 31 wasted)
uint256 public timestamp; // Slot 2 (32 bytes)
address public owner; // Slot 3 (20 bytes + 12 wasted)
bool public isPaused; // Slot 4 (1 byte + 31 wasted)
uint8 public decimals; // Slot 5 (1 byte + 31 wasted)
}
// GOOD: 3 storage slots (50% savings!)
contract Optimized {
uint256 public amount; // Slot 0 (32 bytes)
uint256 public timestamp; // Slot 1 (32 bytes)
address public owner; // Slot 2: 20 bytes |
bool public isActive; // 1 byte | packed
bool public isPaused; // 1 byte |
uint8 public decimals; // 1 byte |
}
// Struct packing
contract StructPacking {
// BAD: 3 slots
struct UserBad {
uint256 balance; // Slot 0
bool isVIP; // Slot 1 (31 bytes wasted)
uint256 lastActive; // Slot 2
}
// GOOD: 2 slots
struct UserGood {
uint256 balance; // Slot 0
uint128 lastActive; // Slot 1: 16 bytes |
bool isVIP; // 1 byte | packed
uint8 level; // 1 byte |
}
// uint128 max ≈ 3.4e38 - more than enough for timestamps
}
Variable ordering determines storage packing. Solidity fills 32-byte slots sequentially. Grouping small types together saves slots. The Optimized contract saves 3 slots (60,000+ gas on initial writes) by reordering variables.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract GasOptimized {
// IMMUTABLE: free to read (embedded in bytecode)
address public immutable OWNER;
uint256 public immutable FEE_BPS;
uint256 public constant MAX_SUPPLY = 1_000_000e18; // Also free
mapping(address => uint256) public balances;
constructor(address _owner, uint256 _fee) {
OWNER = _owner;
FEE_BPS = _fee;
}
// BAD: copies array to memory (~300 gas per element)
function sumBad(uint256[] memory data) external pure returns (uint256 total) {
for (uint256 i = 0; i < data.length; i++) {
total += data[i];
}
}
// GOOD: reads from calldata (no copy) + unchecked increment
function sumGood(uint256[] calldata data) external pure returns (uint256 total) {
for (uint256 i = 0; i < data.length;) {
total += data[i];
unchecked { ++i; }
}
}
// GOOD: cache storage reads
function transfer(address to, uint256 amount) external {
uint256 senderBal = balances[msg.sender]; // 1 SLOAD
require(senderBal >= amount);
unchecked {
balances[msg.sender] = senderBal - amount; // 1 SSTORE
}
balances[to] += amount;
}
// Custom errors (4 bytes) vs string messages (100+ bytes)
error InsufficientBalance(uint256 available, uint256 required);
error ZeroAmount();
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 bal = balances[msg.sender];
if (bal < amount) revert InsufficientBalance(bal, amount);
unchecked { balances[msg.sender] = bal - amount; }
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok);
}
// Batch operations: 1 tx vs N txs
function batchTransfer(
address[] calldata recipients,
uint256[] calldata amounts
) external {
require(recipients.length == amounts.length);
uint256 total;
uint256 len = recipients.length; // Cache length
for (uint256 i; i < len;) {
total += amounts[i];
balances[recipients[i]] += amounts[i];
unchecked { ++i; }
}
require(balances[msg.sender] >= total);
unchecked { balances[msg.sender] -= total; }
}
}
Key optimizations: immutable for constructor-set values (free reads), calldata for read-only inputs (no copy cost), unchecked loop increments, storage caching in locals, custom errors (smaller bytecode), and batch operations (one base gas cost).
// test/GasBenchmark.test.ts
import { expect } from "chai";
import { ethers } from "hardhat";
import { loadFixture } from "@nomicfoundation/hardhat-network-helpers";
describe("Gas Benchmarks", function () {
async function fixture() {
const C = await ethers.getContractFactory("GasOptimized");
const [owner, ...users] = await ethers.getSigners();
const c = await C.deploy(owner.address, 250);
return { c, owner, users };
}
it("calldata vs memory for array input", async function () {
const { c } = await loadFixture(fixture);
const data = Array.from({ length: 100 }, (_, i) => i + 1);
const txBad = await c.sumBad(data);
const receiptBad = await txBad.wait();
const txGood = await c.sumGood(data);
const receiptGood = await txGood.wait();
console.log(`memory: ${receiptBad!.gasUsed} gas`);
console.log(`calldata: ${receiptGood!.gasUsed} gas`);
console.log(`Savings: ${receiptBad!.gasUsed - receiptGood!.gasUsed} gas`);
expect(receiptGood!.gasUsed).to.be.lessThan(receiptBad!.gasUsed);
});
it("batch vs individual transfers", async function () {
const { c, users } = await loadFixture(fixture);
// Setup balances, then compare:
// 10 individual transfers vs 1 batch of 10
// Batch saves ~10 * 21000 = 210,000 gas in base costs alone
});
});
Gas benchmarking tests compare optimization patterns with real measurements. Use hardhat-gas-reporter and benchmarking tests to verify optimizations actually save gas.
Gas Optimization, Storage Packing, Calldata, Immutable, Batch Operations, EVM Opcodes