Solidity Gas Optimization: 10 Techniques to Reduce Transaction Costs
When I first deployed a liquidity pool contract to mainnet, my swap function cost 180k gas. A colleague casually mentioned they'd seen similar logic run for 85k on another protocol, and I felt the panic set in — was I just burning users' money for no reason?
Turns out, I wasn't optimizing storage access patterns, and my calldata was structured like a toddler's LEGO creation. Tbh, once I started actually measuring and thinking about what the EVM does under the hood, the improvements were almost embarrassing in hindsight.
Let's talk about the ten techniques that actually move the needle.
1. Pack Your Storage Variables
The EVM stores data in 32-byte slots. If you declare uint256 (32 bytes) next to uint128 (16 bytes), you're wasting a slot.
// BAD: 3 slots
uint256 price;
uint128 amount;
bool isActive;
// GOOD: 1 slot
uint128 amount;
uint128 price;
bool isActive;
That's a 2100 gas difference per write (SSTORE). On a swap function that writes once, not huge. On an AMM that's hit 10k times a day? You're talking real savings.
2. Use Immutable and Constant for Fixed Values
constant values aren't stored in storage — they're inlined at compile time. immutable is set once in the constructor, then hardcoded during deployment.
// BAD: storage read every time (100 gas)
address owner;
// GOOD: hardcoded at deployment (0 gas on read)
address immutable owner;
constructor(address _owner) {
owner = _owner;
}
Uniswap V3 uses this everywhere. Their factory address, their fee tiers — all hardcoded. No storage lookups.
3. Minimize Storage Reads and Writes
Every SSTORE costs 20k gas the first time (if new), 5k if updating. SLOAD is 2.1k for warm slots, 100 cold. Cache values in memory.
// BAD: reads from storage 3 times
function updateAndCheck() external {
require(balances[msg.sender] > 0);
balances[msg.sender] -= 100;
require(balances[msg.sender] < 1000);
}
// GOOD: reads once, uses memory
function updateAndCheck() external {
uint256 balance = balances[msg.sender];
require(balance > 0);
balance -= 100;
require(balance < 1000);
balances[msg.sender] = balance;
}
The second version saves ~4k gas. (I wrote the first version without thinking. Don't be like first-me.)
4. Use Bytes32 Instead of String for Fixed Data
Strings are expensive. If you're storing a fixed-length identifier or symbol, use bytes32.
// BAD: dynamic storage, expensive hashing
string public name = "MyToken";
// GOOD: static, hashable, cheaply comparable
bytes32 public constant NAME = "MyToken";
OpenZeppelin went back and forth on this before settling on strings for ERC20 compatibility, but internal data uses bytes32.
5. Avoid Unnecessary External Calls
Every external call is CALL opcode (base 700 gas) plus calldata encoding. Batch them when possible. Curve Finance uses internal balance accounting instead of calling transfer() repeatedly.
// BAD: 3 external calls in a loop
for (uint i = 0; i < users.length; i++) {
token.transfer(users[i], amounts[i]);
}
// GOOD: batch in the caller
token.transferBatch(users, amounts);
6. Use uint256 Over Smaller Integers
The EVM word size is 256 bits. Operations on uint128 or uint64 often require masking operations, which add gas.
// BAD: 128-bit operations cost extra
uint128 x = 100;
uint128 y = 200;
uint128 z = x + y;
// GOOD: native word size
uint256 x = 100;
uint256 y = 200;
uint256 z = x + y;
Use smaller types only if you're actually packing them in storage. Otherwise, let the EVM breathe.
7. Use Unchecked Math When Safe
Arithmetic overflow/underflow is checked by default in Solidity 0.8+. If you're certain there's no overflow (like a counter in a bounded loop), drop the safety rails.
// BAD: 500 gas per iteration for overflow check
for (uint i = 0; i < 100; i++) {
sum += values[i];
}
// GOOD: saves ~50k on large loops
for (uint i = 0; i < 100; i++) {
unchecked { sum += values[i]; }
}
OpenZeppelin's ReentrancyGuard uses unchecked for the nonce counter.
8. Use Bit Shifting Instead of Exponentiation
2**n requires computation. 1 << n is a single opcode (SHL).
// BAD: exponentiation
uint256 scaled = amount * (10 ** 18);
// GOOD: bit shift (when possible)
uint256 shifted = amount << 4; // multiply by 16
Only works for powers of 2. Compound uses this for fee calculations.
9. Check Before You Change
Put require checks before state-changing operations so you don't pay for reverted storage changes.
// BAD: state change before check
function withdraw(uint amount) external {
balances[msg.sender] -= amount;
require(balances[msg.sender] >= 0);
}
// GOOD: check before change
function withdraw(uint amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
}
If the check fails, the first version still charges for the storage write. (That's a real gas leak people miss.)
10. Use Events Instead of Storage for Logging
Events are logged, not stored. They're way cheaper than writing to state.
// BAD: ~20k gas per update
struct History { uint amount; uint timestamp; }
History[] history;
function log(uint amount) {
history.push(History(amount, block.timestamp));
}
// GOOD: ~375 gas
event LogAmount(uint indexed amount, uint timestamp);
function log(uint amount) {
emit LogAmount(amount, block.timestamp);
}
That's literally what events exist for — queryable logs that don't bloat storage.
Common Gotchas When Optimizing
Premature optimization will make your code unreadable. Use tools like Hardhat's gas reporter and profile first. Don't guess.
Packing storage across inheritance breaks. If a parent contract adds fields, your child's offsets shift. Uniswap V3 had to be careful here. Document your packing strategy.
Memory isn't always cheaper. Copying large arrays to memory costs more than reading from storage once. Know the tradeoff.
Solidity compiler versions matter — 0.8.20+ uses the Shanghai EVM with cheaper PUSH0. Your optimizations might not be portable to chains still on older opcodes.
Calldata is cheaper to read than memory. If you're accepting uint256[] externally, read it directly from calldata instead of copying to memory.
Here's what I realized after that initial panic: gas optimization isn't black magic. It's just understanding what operations cost and structuring your code to minimize expensive ones. The EVM is deterministic — you pay for what you compute, and you can measure it. My 180k swap function? Down to 92k with these techniques. Users noticed. Transactions confirmed faster. TVL increased.
That's the real win.
Top comments (0)