BlockchainSoliditySmart Contracts12 min readUpdated

Loop Gas Optimization in Solidity: Five Techniques

By Mudassir Khan — Agentic AI Consultant & AI Systems Architect, Islamabad, Pakistan

Cover illustration for: Loop Gas Optimization in Solidity: Five Techniques

Section 01 · Background

Why loops are expensive on the EVM

Each iteration of a loop pays gas for the branch instruction, the condition evaluation, and every opcode inside the body. The cost compounds linearly with iteration count.

Quick answer

The core rule: Every opcode inside a loop body multiplies its individual cost by the number of iterations. A single SLOAD costs 2 100 gas. Inside a 100-iteration loop it costs 210 000 gas — more than many entire transactions. Move expensive opcodes outside the loop whenever possible.

The EVM charges gas per opcode. A JUMPI (branch) costs 10 gas. An ADD costs 3 gas. An SLOAD (storage read) costs 2 100 gas cold or 100 gas warm. An SSTORE (storage write) costs 20 000 gas for a zero-to-nonzero write or 2 900 gas for a nonzero-to-nonzero write. Loop optimization is largely the practice of identifying which of these expensive opcodes can be moved outside the loop body or eliminated entirely.

solidity
// ✗ Slow version — three expensive mistakes in one loop
uint256[] storage scores; // storage array

for (uint256 i = 0; i < scores.length; i++) {
    //                  ^^^^^^^^^^^^^ reads storage every iteration (expensive!)
    totalScore += scores[i];
    //           ^^^^^^^^^^ totalScore is also in storage — written every iteration!
    //  i++  creates a temporary copy of i — wastes 3 gas per iteration
}

// ✓ Fast version — all five techniques applied
uint256 len = scores.length; // read storage length ONCE
uint256 sum = 0;             // accumulate in cheap memory variable

for (uint256 i = 0; i < len; ) {
    sum += scores[i];        // only scores[i] reads storage (unavoidable)
    unchecked { ++i; }       // no overflow check needed — saves ~60 gas/iter
}
totalScore = sum;            // write to storage ONCE after the loop

The length-caching SLOAD is warm, not cold

Inside a function, arr.length is accessed via MLOAD (if memory array) or SLOAD (if storage). For a storage dynamic array, reading .length on every iteration is a warm SLOAD (100 gas) after the first cold read (2 100 gas). Caching it saves 100 gas per iteration — small per iteration but significant at scale. For memory arrays, .length is essentially free, so caching has no effect.

Section 02 · Techniques

Five loop gas optimization techniques

These five techniques can be applied independently or together. Each targets a specific category of wasted gas.

Five-row table listing loop gas optimization techniques: cache length, unchecked increment, prefix plus plus i, memory cache for storage reads, and accumulate in memory.
Apply all five techniques together on hot loops. The combined saving on a 100-iteration loop can exceed 100 000 gas.

Cache array length before the loop

Assign arr.length to a local uint256 before the loop header and use that local in the condition. For storage arrays this avoids a warm SLOAD (100 gas) on every iteration. Pattern: uint256 len = arr.length; for (uint256 i = 0; i < len; )

Wrap the counter in unchecked

In Solidity 0.8.x the compiler inserts overflow checks on every arithmetic operation. A loop counter incremented by 1 from 0 to len can never overflow uint256, so the check is provably unnecessary. Wrapping ++i in unchecked { ++i; } removes the check and saves approximately 60 gas per iteration.

Use ++i instead of i++

The postfix form i++ creates a temporary copy of i before incrementing, then returns the copy. In a loop post statement the copy is discarded immediately. The prefix form ++i increments in place and returns the new value, avoiding the temporary. This saves 3 gas per iteration on most compilers.

Cache repeated storage reads in memory

If the loop body reads the same storage variable more than once, or if a storage variable is read on every iteration, assign it to a local variable before the loop. The first SLOAD (2 100 gas cold or 100 gas warm) is paid once. All subsequent accesses use the cheaper local variable.

Accumulate in memory and write to storage once

If the loop accumulates a running total into a storage variable, move the accumulation into a local uint256 and write it to storage once after the loop. Replacing n storage writes with 1 is the single largest gas saving available in most loops.

Section 03 · Deep dive

unchecked increment: how and why it works

The unchecked block is the most impactful single-line optimization for tight loops. Understanding why it is safe is essential before applying it.

solidity
// ✗ Default — Solidity 0.8 adds overflow check on every i++
for (uint256 i = 0; i < len; i++) {
    sum += arr[i];
}
// Each i++ compiles to: check overflow, then increment. ~60 extra gas/iter.

// ✓ Optimised — wrap only the counter in unchecked
for (uint256 i = 0; i < len; ) {
    sum += arr[i];
    unchecked { ++i; } // no overflow check on the counter — safe because
                       // i can never reach uint256.max in any real array
}
// Saves ~60 gas per iteration. On 100 iterations = ~6 000 gas saved.

Always wrap only the counter increment in unchecked, not the whole body. The loop condition i < len still runs with full overflow protection outside the block. Keeping the unchecked scope as narrow as possible means you only give up overflow protection where you have proven it is unnecessary — everywhere else Solidity 0.8 still protects you.

When is unchecked unsafe on a loop counter?

If len is type(uint256).max and i starts at 0, incrementing i unchecked will eventually wrap to 0, creating an infinite loop. In practice, storage arrays cannot hold 2^256 elements, and any array you iterate over has a bounded length well below uint256.max. The risk is theoretical, not practical for standard Solidity contracts. If len comes from untrusted input, validate it with a MAX constant before the loop.

Section 04 · Full contract

Complete example: GasOptimizedAirdrop contract

This contract distributes a fixed reward per address from a whitelist. It applies all five optimization techniques. The optimized version costs approximately 40% less gas per call than the naive version.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

interface IERC20 {
    function transfer(address to, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

/// @title GasOptimizedAirdrop
/// @notice Distributes a fixed reward to a list of recipients.
///         Demonstrates all five loop gas optimization techniques.
///         Capped at MAX_RECIPIENTS to prevent unbounded gas usage.
contract GasOptimizedAirdrop {

    uint256 public constant MAX_RECIPIENTS = 200;

    IERC20  public immutable token;
    address public owner;

    address[] public recipients;
    mapping(address => bool) public claimed;
    uint256 public rewardPerAddress;

    event AirdropExecuted(uint256 recipientCount, uint256 totalDistributed);

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    constructor(address _token, uint256 _rewardPerAddress) {
        token = IERC20(_token);
        owner = msg.sender;
        rewardPerAddress = _rewardPerAddress;
    }

    // ── Write ────────────────────────────────────────────────────────

    /// @notice Add recipients to the airdrop list (owner only).
    function addRecipients(address[] calldata _recipients) external onlyOwner {
        uint256 newLen = recipients.length + _recipients.length;
        require(newLen <= MAX_RECIPIENTS, "exceeds max");
        uint256 len = _recipients.length;                 // Technique 1: cache length
        for (uint256 i = 0; i < len; ) {
            recipients.push(_recipients[i]);
            unchecked { ++i; }                            // Technique 2+3: unchecked ++i
        }
    }

    /// @notice Execute the airdrop.
    ///         Skips addresses that have already claimed.
    function distribute() external onlyOwner {
        // Technique 1: cache storage array length once
        uint256 len = recipients.length;
        require(len > 0, "no recipients");

        // Technique 4: cache storage variable read in every iteration
        uint256 reward = rewardPerAddress;

        // Technique 5: accumulate total in memory, emit once after loop
        uint256 totalSent = 0;

        for (uint256 i = 0; i < len; ) {
            address recipient = recipients[i]; // 1 SLOAD per iteration (unavoidable)

            if (!claimed[recipient]) {
                claimed[recipient] = true;     // 1 SSTORE (write only when needed)
                require(
                    token.transfer(recipient, reward),
                    "transfer failed"
                );
                unchecked { totalSent += reward; } // safe: totalSent <= MAX_RECIPIENTS * reward
            }

            unchecked { ++i; }                 // Technique 2+3: unchecked ++i
        }

        emit AirdropExecuted(len, totalSent);  // Technique 5: single emit after loop
    }

    // ── Admin ────────────────────────────────────────────────────────

    /// @notice Update reward amount (owner only, before distribution).
    function setReward(uint256 _reward) external onlyOwner {
        require(_reward > 0, "reward must be positive");
        rewardPerAddress = _reward;
    }

    /// @notice Withdraw remaining tokens (owner only).
    function withdraw(uint256 amount) external onlyOwner {
        require(token.transfer(owner, amount), "withdraw failed");
    }

    // ── Read ─────────────────────────────────────────────────────────

    /// @notice Count unclaimed recipients without iterating on-chain.
    ///         Off-chain callers should use this to estimate gas before calling distribute().
    function unclaimedCount() external view returns (uint256 count) {
        uint256 len = recipients.length;
        for (uint256 i = 0; i < len; ) {
            if (!claimed[recipients[i]]) {
                unchecked { ++count; }
            }
            unchecked { ++i; }
        }
    }
}

Section 05 · Anti-patterns

Four loop patterns that waste gas

These patterns appear in production contracts. Each one has a direct, low-effort replacement.

Reading arr.length in every condition check

for (uint256 i = 0; i < arr.length; ++i) reads arr.length on every iteration. For a storage array this is a warm SLOAD (100 gas) per iteration. Fix: uint256 len = arr.length; before the loop, then i < len in the condition.

Writing an accumulator to storage inside the loop

totalScore += scores[i] where totalScore is a storage variable costs a warm SLOAD plus an SSTORE on every iteration. An SSTORE for nonzero-to-nonzero is 2 900 gas. Over 100 iterations that is 290 000 gas in writes alone. Fix: accumulate in a local variable, then do a single SSTORE after the loop.

Emitting an event on every iteration

emit Transfer(from, recipients[i], amount) inside a 100-element loop emits 100 events. Each LOG opcode costs at least 375 gas plus 375 per topic. Fix: if the per-item event is not required by a standard (such as ERC-20 Transfer), emit a single batch event after the loop summarizing the operation.

Deleting array elements inside a forward loop

Deleting elements while iterating forward corrupts the index if elements are shifted. Beyond correctness, delete on a storage slot writes a zero value (SSTORE: up to 2 900 gas per element). If the goal is to clear an entire array, set its length to zero: delete myArray; — this costs a flat fee rather than per-element SSTOREs.

Section 07 · FAQ

Frequently asked questions

Answers to the most common questions about loop gas optimization in Solidity.

How much gas does unchecked save per loop iteration?

Approximately 60 gas per iteration in Solidity 0.8.x. The overflow check on ++i compiles to an additional comparison and JUMPI instruction. Wrapping the increment in unchecked removes these opcodes. On a 500-iteration loop this saves around 30 000 gas, which is comparable to the cost of a simple token transfer.

Is it safe to use unchecked on a loop counter?

Yes, with one condition: the array length must be bounded well below type(uint256).max. For any practical Solidity contract, storage arrays cannot hold 2^256 elements, so a counter starting at 0 and incrementing by 1 cannot overflow before the condition i < len becomes false. If len comes from untrusted input, validate it with a MAX constant before the loop.

Does caching array length always save gas?

Yes for storage arrays, no for memory arrays. A storage array's length is a storage variable — reading it costs 2 100 gas cold or 100 gas warm per access. Caching it saves these reads. A memory array's length is stored in memory (MLOAD: 3 gas), so caching has negligible effect. Always cache for storage arrays; the optimization is free and cannot hurt.

Can I put the entire loop body in unchecked to save even more gas?

You can, but it removes overflow protection from all arithmetic in the body. This is only safe if you have verified that every addition, subtraction, and multiplication inside the body cannot overflow for the valid input range. Putting only the counter increment in unchecked is the conservative and recommended approach for most code.

What is the gas cost of a single empty loop iteration?

An empty for loop iteration costs approximately 17 gas: 10 gas for the JUMPI instruction plus 3 gas each for the counter read, comparison, and increment. Adding SLOAD operations inside the body increases this to hundreds or thousands of gas per iteration. The base loop overhead is small — the cost comes from what is inside the body.

Is it cheaper to loop in Solidity or off chain?

Off chain is almost always cheaper when the result does not need on chain verification. A loop that runs in your indexer or backend costs nothing in gas. The on chain function then accepts the computed result and trusts or proves it. For results that must be trustless, like Merkle proof verification, the on chain loop is required, but the loop is bounded to log2 of the set size and stays cheap.

How do I benchmark loop gas costs in Solidity?

Use Foundry's forge test --gas-report or Hardhat's hardhat-gas-reporter plugin. Both run your tests and print gas used per function call. For a single loop, write two test functions, one with the optimization and one without, then call each with the same input array. The diff between the two gas numbers is your saving per loop.

Written by Mudassir Khan

Agentic AI consultant and AI systems architect based in Islamabad, Pakistan. CEO of Cube A Cloud. 38+ agentic AI launches delivered for global founders and CTOs.

View agentic AI consulting serviceSee ChainTrust case study

Related service

Agentic AI Consulting

See scope & pricing →

Related case study

ChainTrust Compliance Engine

Read case study →

More on this topic

Need an AI systems architect?

Book a 30-minute architecture call. I will sketch the high-level design for your use case and give you an honest view of the trade-offs.

Book a strategy call →