Why loops need special attention in Solidity

In Solidity, the cost of a loop is not just CPU time. It is gas, and gas is a hard execution limit. A function that works fine with 10 items may fail with 1,000 items, even if the logic is correct. That makes loops a design problem, not just an implementation detail.

The main risks are:

  • Unbounded iteration over user-controlled or growing data
  • State changes during iteration that alter the loop’s behavior
  • External calls inside loops that introduce reentrancy or partial completion risks
  • Storage-heavy iteration that becomes too expensive over time

A good rule of thumb is simple: if a loop can grow with contract usage, design for a maximum bound or a resumable workflow.


Prefer bounded loops over “iterate everything” logic

The most common mistake is assuming a contract can safely process all stored items in one transaction. That may work early on, but it often breaks as the dataset grows.

Bad pattern: process all items at once

function distributeRewards(address[] calldata users) external {
    for (uint256 i = 0; i < users.length; i++) {
        _credit(users[i], 1 ether);
    }
}

This function is only safe if users.length is always small and trusted. If a caller can pass a large array, the transaction may run out of gas. If the function reads from storage instead of calldata, the cost becomes even higher.

Better pattern: process in bounded batches

function distributeRewards(address[] calldata users, uint256 maxCount) external {
    uint256 count = users.length < maxCount ? users.length : maxCount;

    for (uint256 i = 0; i < count; i++) {
        _credit(users[i], 1 ether);
    }
}

This still needs a sensible maxCount, but it gives the caller and the protocol a clear upper limit. In production systems, batch size should be chosen based on worst-case gas estimates, not just average usage.


Use resumable processing for large datasets

When the full dataset is too large for one transaction, the loop should be split across multiple calls. This is common in reward distribution, cleanup jobs, migrations, and airdrops.

A resumable pattern stores progress in contract state and continues from the last processed index.

contract BatchProcessor {
    address[] public recipients;
    uint256 public nextIndex;

    function process(uint256 batchSize) external {
        uint256 end = nextIndex + batchSize;
        if (end > recipients.length) {
            end = recipients.length;
        }

        for (uint256 i = nextIndex; i < end; i++) {
            _processRecipient(recipients[i]);
        }

        nextIndex = end;
    }

    function _processRecipient(address recipient) internal {
        // application-specific logic
    }
}

Why this pattern helps

  • Each transaction has a predictable upper bound
  • Processing can resume after failure
  • The contract does not depend on a single “all or nothing” call

Important caveat

If the loop updates nextIndex only after the loop finishes, a revert resets progress. That is often desirable for atomicity. If you need partial progress to survive failures, you must design for idempotency and carefully update state as you go.


Avoid external calls inside loops when possible

A loop that performs external calls is much harder to reason about than a pure internal loop. Each call can fail, consume unexpected gas, or reenter the contract.

Risky example

function payMany(address[] calldata recipients, uint256 amount) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        (bool ok, ) = recipients[i].call{value: amount}("");
        require(ok, "payment failed");
    }
}

Problems with this design:

  • A single failing recipient reverts the entire batch
  • A malicious recipient may reenter during the loop
  • Gas usage becomes unpredictable
  • Partial progress is lost on revert

Safer alternatives

PatternWhen to useTradeoff
Pull-based claimsRecipients can claim individuallyMore user interaction
Precompute state, then settle laterLarge batches or complex logicMore storage
Internal accounting onlyNo immediate transfer neededRequires later withdrawal flow

If you must call external contracts in a loop, keep the loop small, apply checks-effects-interactions, and consider isolating each recipient into its own transaction.


Be careful when looping over storage arrays

Storage iteration is expensive because each read costs gas, and the cost grows with the array size. A loop over storage is often acceptable for small, fixed-size collections, but dangerous for dynamic sets.

Example: storage iteration

address[] public members;

function countActiveMembers() external view returns (uint256 count) {
    for (uint256 i = 0; i < members.length; i++) {
        if (_isActive(members[i])) {
            count++;
        }
    }
}

This view function may still be too expensive to call on-chain if members becomes large. Even though it is view, other contracts calling it during execution will pay the gas cost.

Practical guidance

  • Use storage loops only for small, bounded collections
  • Prefer indexing structures that support direct lookup
  • Cache members.length in a local variable when the array length does not change during the loop
  • Avoid nested storage loops unless the data is guaranteed to remain tiny

A small optimization that also improves readability:

uint256 length = members.length;
for (uint256 i = 0; i < length; i++) {
    // ...
}

This avoids repeated storage reads of members.length.


Design loops to be idempotent

A loop is idempotent if repeating it does not create incorrect state. This matters when transactions can be retried, partially executed in off-chain orchestration, or resumed after a failure.

Example of non-idempotent behavior

function markProcessed(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        processed[ids[i]] = true;
        totalProcessed++;
    }
}

If this function is called twice with the same IDs, totalProcessed becomes incorrect.

Better approach

Use state checks to make repeated processing safe:

function markProcessed(uint256[] calldata ids) external {
    for (uint256 i = 0; i < ids.length; i++) {
        uint256 id = ids[i];
        if (!processed[id]) {
            processed[id] = true;
            totalProcessed++;
        }
    }
}

This pattern is especially useful in batch jobs, migration scripts, and claim systems where duplicate inputs are possible.


Watch for loop-dependent state changes

Loops that depend on mutable state can behave unexpectedly if that state changes during iteration. This is especially important when the loop condition reads from storage that the loop body also updates.

Example: shrinking array during iteration

function removeExpired() external {
    for (uint256 i = 0; i < items.length; i++) {
        if (items[i].expired) {
            _removeAt(i);
        }
    }
}

If _removeAt(i) swaps the last element into position i, the next item may be skipped because the array contents changed. This is a classic off-by-one bug in Solidity loops.

Safer strategies

  • Iterate backward when removing items
  • Collect indices first, then remove in a second pass
  • Use swap-and-pop carefully and document the iteration assumptions

Backward iteration is often the simplest fix:

function removeExpired() external {
    for (uint256 i = items.length; i > 0; i--) {
        uint256 index = i - 1;
        if (items[index].expired) {
            _removeAt(index);
        }
    }
}

Choose the right loop shape for the job

Not all loops are equally safe. Some patterns are easier to audit and scale better than others.

Loop shapeBest use caseMain risk
for with fixed upper boundBounded batch workIncorrect bound selection
for over calldata arrayUser-submitted batchesLarge calldata payloads
for over storage arraySmall on-chain collectionsGas blowups
while loopsState-driven progressHarder to prove termination
Nested loopsSmall matrix-like dataRapid gas growth

In Solidity, for loops with explicit bounds are usually the easiest to review. while loops should be used only when the termination condition is simple and guaranteed.


Practical checklist for safe loop design

Before shipping a loop, ask these questions:

  1. Can the loop grow without limit?
  • If yes, redesign it as a batch or resumable process.
  1. Does the loop read from storage on every iteration?
  • If yes, cache values where possible and keep the collection small.
  1. Does the loop make external calls?
  • If yes, consider moving those calls out of the loop.
  1. Can the loop be safely repeated?
  • If not, add guards to prevent double counting or duplicate state changes.
  1. Can the loop terminate unexpectedly due to gas?
  • If yes, define a maximum batch size and test worst-case execution.
  1. Does the loop mutate the data structure it iterates over?
  • If yes, verify index behavior carefully.

A realistic example: batch claims with progress tracking

The following pattern combines several best practices: bounded work, resumable progress, and idempotent claims.

contract ClaimProcessor {
    struct Claim {
        address account;
        uint256 amount;
        bool claimed;
    }

    Claim[] public claims;
    uint256 public nextClaimIndex;

    mapping(address => uint256) public balances;

    function processClaims(uint256 batchSize) external {
        uint256 end = nextClaimIndex + batchSize;
        if (end > claims.length) {
            end = claims.length;
        }

        for (uint256 i = nextClaimIndex; i < end; i++) {
            Claim storage claim = claims[i];
            if (!claim.claimed) {
                claim.claimed = true;
                balances[claim.account] += claim.amount;
            }
        }

        nextClaimIndex = end;
    }
}

Why this works well

  • The batch size is caller-controlled but bounded
  • Each claim is marked before crediting balance
  • Reprocessing the same claim does not double count
  • The contract can continue across multiple transactions

This is not the only valid design, but it is a strong default for large-scale processing.


Conclusion

Safe loop design in Solidity is mostly about controlling growth and making execution predictable. If a loop can expand with contract usage, it should usually be bounded, resumable, or replaced with a pull-based flow. If it touches storage or external contracts, the review bar should be higher.

When in doubt, optimize for termination guarantees, clear gas limits, and idempotent behavior. Those properties matter more than shaving a few lines of code.

Learn more with useful resources