Solidity Gas Optimization Techniques That Actually Move the Needle
Practical Solidity gas optimization techniques for reducing transaction costs — storage layout, calldata, custom errors, and compiler settings that have real impact.
Gas costs are a real product problem. Users abandon transactions when fees are high, and protocols with tight margins on every operation need efficient code to stay competitive. The good news is that the highest-impact optimizations are not exotic — they follow directly from understanding how the EVM handles storage and computation.
Why Gas Optimization Matters in Solidity
The EVM assigns a gas cost to every operation. Storage writes are the most expensive, followed by storage reads, then computation, then memory access. This ordering tells you where to focus: most contracts spend the majority of their gas on storage operations, not arithmetic.
Gas optimization is not about making code clever. It is about making code align with the EVM's cost model.
Solidity Gas Optimization Techniques That Have Real Impact
Pack Storage Variables
The EVM reads and writes storage in 32-byte (256-bit) slots. If you declare variables that together fit in one slot, the compiler can pack them, reducing the number of SLOAD and SSTORE operations:
// Inefficient — three separate slots
uint256 a;
uint128 b;
uint128 c;
// Efficient — b and c share one slot
uint128 b;
uint128 c;
uint256 a;
Order your struct and contract variables by type size so that smaller types are adjacent. This is one of the most impactful layout-level optimizations and costs nothing except attention to declaration order.
Cache Storage Reads in Memory
Every access to a state variable is an SLOAD. Cache values in local variables before loops or repeated use:
// Reads storage on every iteration — expensive
for (uint256 i = 0; i < users.length; i++) { ... }
// Cache once
uint256 len = users.length;
for (uint256 i = 0; i < len; i++) { ... }
On a warm storage slot, SLOAD costs 100 gas. MLOAD (memory read) costs 3 gas. This difference compounds significantly in loops.
Use calldata Instead of memory for Read-Only Parameters
When an external function receives an array or bytes value that it does not modify, declare the parameter as calldata rather than memory:
// memory copies the data — more expensive
function process(uint256[] memory values) external { ... }
// calldata reads in place — cheaper
function process(uint256[] calldata values) external { ... }
calldata parameters are never copied — the function reads directly from the transaction input data. For large arrays, this is a significant saving.
Use Custom Errors Instead of String Reverts
String error messages are stored in bytecode and cost gas on revert. Custom errors are encoded as a 4-byte selector:
// Expensive — string stored in bytecode
require(amount > 0, "Amount must be positive");
// Cheap — 4-byte selector only
error InvalidAmount(uint256 provided);
if (amount == 0) revert InvalidAmount(amount);
Custom errors also give callers structured data to decode, making them strictly better than strings in both cost and DX.
Use immutable and constant for Values Set at Deploy Time
constant variables are inlined into bytecode — no storage, no read cost:
uint256 public constant MAX_SUPPLY = 10_000;
immutable variables are set once in the constructor and then inlined similarly — useful for addresses and values you only know at deploy time:
address public immutable token;
constructor(address _token) { token = _token; }
If a value never changes after deployment, it should be immutable or constant. Using regular state variables for these is a common and unnecessary gas cost.
Use unchecked for Safe Arithmetic
Solidity 0.8.x checks every arithmetic operation for overflow and underflow by default. This is the right default. But in contexts where overflow is provably impossible — such as a loop counter that can never exceed the length of a bounded array — the check is unnecessary:
for (uint256 i = 0; i < len; ) {
// do work
unchecked { ++i; } // safe: i < len guarantees no overflow
}
Also note ++i instead of i++: pre-increment does not create a temporary variable and is slightly cheaper.
Enable and Configure the Optimizer
The Solidity compiler optimizer reduces bytecode size and execution cost. In your Hardhat or Foundry config:
// foundry.toml
[profile.default]
optimizer = true
optimizer_runs = 200
The optimizer_runs parameter tells the optimizer how many times you expect each function to run over the contract's lifetime. A higher value optimizes for runtime cost (larger bytecode, cheaper execution). A lower value optimizes for deployment cost. For most consumer-facing contracts, 200 is a reasonable default.
Minimize On-Chain Storage for Off-Chain Data
If data is only consumed by off-chain systems, emit events instead of writing to storage. Events cost roughly 375 base gas plus a small per-byte cost. A cold SSTORE costs 20,000 gas. For state that an indexer or frontend will read but that no contract logic depends on, events are the correct mechanism.
Batch Operations Where Possible
A transaction has a fixed base cost of 21,000 gas. If users can send one transaction that does ten operations instead of ten separate transactions, they save on base cost. Design batch functions for high-frequency user operations.
Measuring Before and After
Do not optimize without measuring. Foundry's gas snapshot creates a baseline:
forge snapshot
Make your changes, then run forge snapshot --diff to see exactly which functions improved and by how much. Never ship an optimization that you have not measured.
Gas optimization is a discipline, not a one-time pass. Build measurement into your CI pipeline so regressions are caught before they reach users.
If you need a contract system that is designed for gas efficiency from the architecture down, Clixo builds production Solidity systems where optimization is part of the engineering process, not an afterthought.