Status DataClose notification

Solidity Gas Optimization: 12 Techniques and the Security Trade-offs Auditors Check

Andrii Stepanov
Andrii StepanovSecurity Researcher
9 Minutes ReadPublished: 22 Sep 2023

TL;DR: Solidity gas optimization means touching storage less, reading calldata in place, and skipping work the compiler doesn't need. Each trick has a failure mode: stale caches, silent truncation, overflow, poisoned transient slots. Measure, optimize, then audit the change.

Solidity gas optimization is the practice of writing smart contracts that cost less to deploy and execute on the EVM. Gas optimization in Solidity matters on every EVM chain, since smart contract gas optimization decisions are baked into code that can't be changed after deployment. Fees on Ethereum mainnet and L2s are well below 2021 peaks, but the problem remains. A function that burns 40% more gas than it needs makes every user pay that 40% forever, because deployed code doesn't change. It also hits a hard ceiling sooner: since Fusaka, a single transaction can't use more than 16,777,216 gas.

Gas optimization is also a security topic. Many common gas tricks remove a safety check, reorder state changes, or hand-roll something the compiler used to do safely. This guide covers 12 techniques that work in Solidity 0.8.x today, what each saves, and what each can break.

How Gas Works in 2026

Every EVM opcode has a fixed gas cost. Some are dynamic, such as storage writes, memory expansion, and cold account access. A transaction pays gas used × (base fee + priority fee). Since EIP-1559, the base fee is burned. Since the Merge, the priority fee goes to the validator that proposes the block (there are no miners on Ethereum anymore).

Three protocol changes shape optimization work today:

  • Berlin (EIP-2929) made the first access to a storage slot or account in a transaction "cold" and expensive, with later accesses "warm" and cheap. Caching and access patterns matter more than raw opcode counts.
  • London (EIP-3529) capped gas refunds at one-fifth of gas used and removed SELFDESTRUCT refunds, which killed gas-token tricks.
  • Fusaka (December 2025) added a per-transaction gas cap of 2²⁴ (16,777,216) through EIP-7825, raised the default block gas limit to 60M, and introduced the CLZ opcode (EIP-7939).

Gas cost reference

Operation Gas

Transaction base cost

21,000

SLOAD cold / warm

2,100 / 100

SSTORE zero → non-zero

20,000 (+2,100 if cold)

SSTORE non-zero → non-zero

2,900 (+2,100 if cold)

TLOAD / TSTORE (transient storage)

100 / 100

MLOAD / MSTORE / CALLDATALOAD

3 (+ memory expansion)

LOGn

375 + 375 per topic + 8 per data byte

External call / BALANCE to cold address

2,600 (warm: 100)

Calldata byte, zero / non-zero

4 / 16 (higher floor for data-heavy txs since EIP-7623)

One number explains most of this guide: a storage write that sets a slot from zero costs 20,000 gas (22,100 if the slot is cold), roughly 7,000× more than writing to memory.

How to Measure Smart Contract Gas Efficiency

Don't guess. Get a baseline, change one thing, and measure again. The same workflow is how you evaluate competing implementations of a contract, such as two AMM designs or two token standards: run the same test scenarios against both and compare the gas reports.

You don't need to spend real gas for any of this. Run tests against a local node (Foundry's Anvil or Hardhat's built-in simulated network), fork mainnet locally to measure against real contract state, and use a testnet such as Sepolia only for final end-to-end checks.

  • Foundry: forge test --gas-report gives per-function min/avg/max. forge snapshot writes a .gas-snapshot file you can diff in CI.
  • Hardhat 3: npx hardhat test --gas-stats prints built-in gas statistics, and gas snapshots are supported for regression checks.
  • Compiler settings: turn on the optimizer and tune runs. A low value optimizes for deployment cost; a high value optimizes for frequently called functions. Try viaIR: true and compare, since the IR pipeline often produces cheaper code for complex contracts.

Truffle and Ganache, which older guides (including the first version of this one) recommend, were sunset by Consensys in 2023.

12 Solidity Gas Optimization Tips and Techniques

Each of these gas optimization techniques comes with a security check: what the change can break, and what an auditor will look for.

1. Cache storage reads, but not across external calls

Reading the same storage variable twice costs a cold SLOAD (2,100) and then a warm one (100). Caching the value in a local variable turns the second read into a 3-gas stack or memory access. In loops, the savings add up.

Security check: a cached value goes stale the moment your contract makes an external call. The classic bug is caching a balance, sending ETH, then writing back the cached number:

// Vulnerable: the cache plus the external call before the write enables reentrancy
function withdraw(uint256 amount) external {
    uint256 bal = balances[msg.sender];          // cached
    if (bal < amount) revert InsufficientBalance();
    (bool ok, ) = msg.sender.call{value: amount}("");
    if (!ok) revert TransferFailed();
    balances[msg.sender] = bal - amount;         // stale write
}

// Fixed: checks-effects-interactions
function withdraw(uint256 amount) external {
    uint256 bal = balances[msg.sender];
    if (bal < amount) revert InsufficientBalance();
    balances[msg.sender] = bal - amount;         // effect first
    (bool ok, ) = msg.sender.call{value: amount}("");
    if (!ok) revert TransferFailed();
}

A reentrant call sees the old balance in storage and withdraws again. Our guide to re-entrancy attacks walks through finding and reporting this class of bug.

2. Pack storage variables

The EVM stores data in 32-byte slots. Variables smaller than 32 bytes declared next to each other share a slot, so writing both costs one SSTORE instead of two.

// 3 slots
uint128 a;
uint256 b;
uint128 c;

// 2 slots: a and c share slot 0
uint128 a;
uint128 c;
uint256 b;

Packing only pays off when the packed fields are read or written together. Otherwise the compiler's masking and shifting can cost more than it saves.

Security check: packing pushes developers toward smaller types, and Solidity's explicit downcast truncates silently. uint128(x) with x = 2**128 stores 0 and doesn't revert. Use OpenZeppelin's SafeCast (x.toUint128()) wherever the input isn't provably bounded. Truncated amounts, timestamps, and prices are a recurring audit finding.

3. Use constant and immutable

constant values are inlined at compile time. immutable values are set once in the constructor and written into the bytecode. Reading either costs a few gas instead of a 2,100-gas cold SLOAD.

uint256 public constant FEE_BPS = 30;
address public immutable treasury;

constructor(address _treasury) { treasury = _treasury; }

Security check: immutables can't be rotated. If a treasury, oracle, or admin address may ever need to change, immutable turns an operational incident into a redeploy. Auditors check that every immutable really is permanent.

4. Read arguments from calldata, not memory

For external functions, calldata reads arguments in place. memory copies them first, paying for memory expansion.

function sum(uint256[] calldata xs) external pure returns (uint256 total) {
    for (uint256 i; i < xs.length; ++i) {
        total += xs[i];
    }
}

Security check: low risk. Note that calldata is read-only, so code that needs to modify the input has to copy it explicitly, and that copy is where length and bounds bugs tend to appear.

5. Use custom errors instead of revert strings

Revert strings are stored in bytecode and ABI-encoded on every revert. Custom errors (Solidity 0.8.4+) use a 4-byte selector, which shrinks deployment size and makes reverts cheaper. Since 0.8.26 (IR pipeline) and 0.8.27 (legacy pipeline), they work directly inside require:

error ZeroAmount();
error NotOwner(address caller);

require(amount > 0, ZeroAmount());
require(msg.sender == owner, NotOwner(msg.sender));

Security check: off-chain systems such as frontends, bots, and monitoring that match on revert strings will break silently after the migration. Update decoders when you switch.

6. Use unchecked only where overflow is proven impossible

Since 0.8.0, Solidity checks every arithmetic operation for overflow and underflow. An unchecked block skips those checks:

if (fee > total) revert FeeTooHigh();
unchecked {
    return total - fee;   // safe: fee <= total checked above
}

You no longer need unchecked { ++i; } in simple loops. Since 0.8.22 the compiler removes the overflow check for loop counters in for (uint256 i; i < n; ++i) automatically.

Security check: this is the riskiest item on the list. unchecked brings back pre-0.8 wraparound behavior. An underflow turns a small balance into ~2²⁵⁶. Every unchecked block needs a proof of safety, either a preceding check or an invariant, written next to it. Auditors read each one.

7. Emit events for data that only off-chain code needs

Storing a 32-byte value in a fresh slot costs about 22,100 gas. Emitting it in an event with one indexed parameter costs about 1,400.

event Deposited(address indexed user, uint256 amount);
emit Deposited(msg.sender, msg.value);

Security check: contracts can't read logs. If any on-chain logic, now or after an upgrade, depends on that data, moving it to events breaks the logic. Indexers also trust whatever the contract emits, so an event that doesn't match actual state changes is a vulnerability for every integrator that relies on it.

8. Use bitmaps for boolean flags

A mapping(address => bool) uses a full slot per flag. A bitmap stores 256 flags per slot, which is how many airdrop and Merkle-distributor contracts track claims:

mapping(uint256 => uint256) private claimedBitMap;

function isClaimed(uint256 index) public view returns (bool) {
    uint256 word = claimedBitMap[index >> 8];
    uint256 mask = 1 << (index & 0xff);
    return word & mask != 0;
}

function _setClaimed(uint256 index) internal {
    claimedBitMap[index >> 8] |= 1 << (index & 0xff);
}

Security check: bit math errors are silent. A wrong shift or mask can let two indices map to the same bit (denying a legitimate claim) or to no bit at all (allowing double claims). Fuzz the index-to-bit mapping.

9. Use transient storage for in-transaction state, and clear it

EIP-1153 (Dencun, March 2024) added TSTORE/TLOAD: storage that costs 100 gas per access and is wiped at the end of the transaction. It's ideal for reentrancy locks and callback flags. Since 0.8.28, Solidity supports transient state variables for value types:

bool transient locked;

modifier nonReentrant() {
    require(!locked, Reentrancy());
    locked = true;
    _;
    locked = false;
}

OpenZeppelin ships this pattern as ReentrancyGuardTransient.

Security check: transient storage persists across every call within the transaction, not just the current call frame. The compiler warns about this: "your contract may unintentionally misbehave when invoked multiple times in a complex transaction... be sure to clear all transient storage at the end of any call to your contract."

The SIR.trading exploit (March 30, 2025, ~$355K, the protocol's entire TVL) shows what happens otherwise. The contract stored the expected Uniswap V3 pool address in transient slot 0x1 to authenticate a swap callback. Later in the same flow, it wrote a user-influenced mint amount into that same slot. The attacker chose the amount so that its numeric value equalled an address they controlled, deployed an attack contract at that address with CREATE2, and passed the callback's authentication check. In simplified form:

function swapCallback(uint256 amount) external {
    address expected;
    assembly { expected := tload(1) }
    if (msg.sender != expected) revert Unauthorized();
    assembly { tstore(1, amount) }   // BUG: the auth slot now holds attacker-chosen data
}

// Fix: clear the auth slot after use, and never reuse a slot for different data
assembly { tstore(1, 0) }
assembly { tstore(2, amount) }

10. Order checks so cheap ones fail first

&& and || short-circuit. Put cheap conditions (comparisons on stack values or calldata) before expensive ones (storage reads, external calls). Validate inputs before touching storage so failing transactions revert early.

Security check: reordering checks can change which error a caller sees, and occasionally whether an external call happens at all. Make sure no check with side effects moves past a revert path it previously preceded.

11. Bound every loop

Looping over a growing array is a gas problem that becomes a denial-of-service bug. Since Fusaka, no transaction can exceed 16,777,216 gas, whatever the block limit. A loop that writes one fresh storage slot per iteration (~22,100 gas) can run about 750 times before the transaction can never succeed again. If users can push items into that array, an attacker can push enough to permanently brick the function: withdrawals, reward distribution, liquidations.

Security check: use pagination (start, count parameters), pull-over-push payouts, or per-user accounting instead of global iteration. Auditors flag any loop whose bound an untrusted party can grow.

12. Stop using transfer and send (the 2,300 gas stipend)

transfer and send forward a fixed 2,300 gas stipend. The Solidity docs' security considerations have long warned about relying on it. That number was a reentrancy defense in 2017, but gas repricings since then mean a recipient contract (a multisig or smart account, for example) can run out of gas inside it, locking funds. Solidity 0.8.31 deprecates both and plans to remove them in 0.9.0.

(bool ok, ) = recipient.call{value: amount}("");
if (!ok) revert TransferFailed();

Security check: call forwards all available gas, so reentrancy protection has to come from checks-effects-interactions or a reentrancy guard (technique #9), not the stipend. Always check the return value.

A note on inline assembly and gas-optimized libraries

Libraries like Solady, plus hand-written Yul, can save significant gas on hashing, math, and memory operations. They also bypass the compiler's safety checks: no overflow protection, no bounds checks, and manual memory management. Use battle-tested libraries over your own assembly, mark assembly blocks memory-safe only when they are, and expect auditors to spend a disproportionate share of their time on every assembly {} block.

Gas Optimizations vs. Security: Audit Checklist

Optimization What can break What auditors check

Caching storage reads

Stale state after external calls, reentrancy

State written before interactions

Storage packing

Silent truncation on downcast

SafeCast or proven bounds

immutable

Can't rotate compromised or deprecated addresses

Each immutable is truly permanent

Custom errors

Off-chain revert decoding

Frontends and bots updated

unchecked

Overflow and underflow

A written safety proof per block

Events instead of storage

On-chain logic can't read logs

No contract depends on the data

Bitmaps

Index collisions, double claims

Fuzzed index-to-bit mapping

Transient storage

Slot reuse within a transaction

Slots cleared after use, one purpose per slot

Check reordering

Skipped side effects

Revert paths unchanged

Unbounded loops

DoS past the 16.7M gas cap

Pagination or pull patterns

call instead of transfer

Reentrancy without the stipend

CEI or a reentrancy guard

Inline assembly

Memory corruption, missing checks

Line-by-line review

This complements our list of Solidity patterns that pass code review but fail in production, which covers logic bugs rather than gas trade-offs.

Solidity Gas Optimization Audit: How Gas Findings Are Rated

A gas optimization review is a standard part of any smart contract audit. Pure gas savings usually show up as informational or low-severity findings. They're worth fixing, but they don't block a launch. Severity jumps when the gas issue has a security impact:

  • DoS: unbounded loops, functions that can be pushed past the transaction gas cap.
  • Gas griefing: a callee or relayer that can make a call fail by controlling how much gas it consumes, especially where return values aren't checked.
  • Locked funds: fixed gas stipends that make legitimate recipients revert.

The same logic applies to bug bounties and audit contests. Most programs scope out "this could be cheaper." A gas issue that freezes withdrawals or bricks a core function is a vulnerability, and gets triaged as one. Read each program's scope before submitting, and frame gas-related reports around impact, not savings. If you're new to reviewing a codebase, our walkthrough on how to read any smart contract project without getting lost is a good place to start.

Static analyzers and AI assistants can flag gas patterns across a whole codebase in minutes. Deciding which findings carry real security impact still takes a human. This AI smart contract auditing workflow shows how researchers combine structured prompts with manual triage to filter out false positives and catch what AI misses.

What's Next: Glamsterdam

Ethereum's next upgrade, Glamsterdam, forks Sepolia on October 6, 2026, with mainnet targeted for later in Q4 2026. Two scheduled changes matter here:

  • EIP-8037 raises the gas cost of creating new state, which makes storage minimization (techniques 1, 2, 7, and 8) more valuable.
  • EIP-7954 raises the 24 KB contract size limit, which eases one reason teams reach for aggressive bytecode-size tricks.

Re-run your gas snapshots against the new fork rules once client support lands. The costs in this article reflect mainnet as of October 2026.

Conclusion

The best Solidity gas optimization tips haven't changed much: touch storage less, read calldata in place, and let the compiler do the safe work for you. What has changed is the cost of getting it wrong. A per-transaction gas cap turns unbounded loops into permanent DoS, and transient storage adds a new class of slot-collision bugs. Treat every optimization as a code change that needs review, because that's how attackers will treat it.

Share article:

More topics: