BlockchainSolidity13 min readUpdated

Solidity Arrays with Loops: Smart Contract Examples

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

Cover illustration for: Solidity Arrays with Loops: Smart Contract Examples

Section 01 · Definition

What arrays with loops actually mean

An array on its own holds the data. A loop walks the data. Putting the two together is how you compute over a list of records on chain — but every iteration costs gas, so the safety question matters as much as the syntax.

Quick answer

What is a loop in Solidity? A loop in Solidity is a control flow statement that repeats a block of code while a condition holds. The three available shapes are for, while, and do while. Loops are most often used to walk an array, sum a running total, find a maximum, or perform a batch operation. Every iteration consumes gas, so loop bounds must be small or paginated to avoid hitting the block gas limit.

The pattern is everywhere: tallying votes across proposals, computing the total supply of a token across holders, finding the highest bidder in an auction, distributing a reward to a list of recipients. All of them are arrays plus loops.

solidity
uint256[] public scores;

function totalScore() external view returns (uint256 total) {
    uint256 len = scores.length;          // cache once
    for (uint256 i; i < len; ++i) {
        total += scores[i];
    }
}

That five line function shows the canonical shape: declare the array, cache its length, set up a counter, walk the indices, accumulate. Every other loop in this guide is a variation of this pattern with a different body.

For the standalone primitives, see arrays in Solidity and mapping in Solidity. This piece assumes you know how to declare an array and focuses on the iteration patterns you will actually write in production.

Section 02 · Syntax

for, while, and do while in Solidity

The three loop shapes look almost exactly like their JavaScript or C counterparts. Pick the one that matches your stop condition, then cache the length and use unchecked where you can prove safety.

Comparison of three Solidity loop patterns: a for loop indexing into an array, a while loop with a manual counter, and a do while loop that runs at least once.
The three loop forms. for is the default. while is for unbounded conditions. do while is rare in on chain code.

The for loop is the workhorse. You declare the counter, the stop condition, and the increment all in one line, and the body runs as long as the condition holds.

solidity
for (uint256 i; i < arr.length; ++i) {
    // body
}

The while loop is for cases where you do not know the iteration count up front. A delegation chain walk, a binary search, or a state driven retry loop are typical examples.

solidity
address current = msg.sender;
while (delegations[current] != address(0)) {
    current = delegations[current];
}
// current is now the head of the delegation chain

The do while loop runs the body once before checking the condition. It rarely appears in on chain code because most Solidity loops require the condition to hold from the start. You may see it in retry helpers and verification routines that must execute one iteration unconditionally.

Three optimisations every loop should have

First, cache arr.length in a local variable before the loop — repeated SLOAD reads on a storage array are wasteful. Second, prefer ++i over i++ because the prefix form does not create a temporary copy. Third, wrap the increment in unchecked { ++i; } when you can prove the counter cannot overflow, which it cannot if the loop bound is anything practical. Together these three changes cut a typical loop's gas by 15 to 30 percent.

Section 03 · Worked example

Tallying votes across an array of proposals

The companion to the voting registry from the mapping piece. Here the goal is to find the proposal with the highest vote count by iterating the proposals array.

Visualization of a for loop iterating over an array of three proposals, comparing each vote count to a running winning index, and arriving at the proposal with the highest count.
The loop carries two pieces of state: the running maximum and the index that produced it. Each iteration updates both if the current proposal wins.

You already have the mapping with struct setup from the companion piece on mapping with struct. Two structs, two mappings, and a parallel proposals array. The contract now needs a function that returns which proposal won. That is a for loop with a running max:

solidity
struct Proposal {
    bytes32  name;
    uint128  voteCount;
    uint64   endsAt;
}

Proposal[] public proposals;

function winningProposal()
    public
    view
    returns (uint256 winnerIndex)
{
    uint256 winningCount;
    uint256 len = proposals.length;
    for (uint256 i; i < len; ++i) {
        if (proposals[i].voteCount > winningCount) {
            winningCount = proposals[i].voteCount;
            winnerIndex = i;
        }
    }
}

function winnerName() external view returns (bytes32) {
    return proposals[winningProposal()].name;
}

A few details earn their keep. The function is view, so calling it through eth_call costs the user no real gas. The loop reads each proposal once. If two proposals tie, the first one found wins because the comparison is strict greater than. This matches the behaviour of the canonical OpenZeppelin governor contract, where ties are resolved by declaration order.

What if you also want to know how many votes the winner received? Return both:

solidity
function winnerWithCount()
    external
    view
    returns (uint256 winnerIndex, uint256 winningCount)
{
    uint256 len = proposals.length;
    for (uint256 i; i < len; ++i) {
        if (proposals[i].voteCount > winningCount) {
            winningCount = proposals[i].voteCount;
            winnerIndex = i;
        }
    }
}

That is the same loop with two named return values. The compiler initialises both to zero, so the first iteration always sets them. The pattern generalises to any walk that returns running aggregates: total supply, average, count above a threshold, first matching index.

Section 04 · Common patterns

Six loop patterns you will write again and again

Different problems call for different loop bodies. These six cover the great majority of array iteration you will do on chain.

Sum a running total

Add every element into an accumulator. Used for total supply across holders, total stake across positions, total fees collected. The body is one line: total += arr[i]. Bound the array size or you will hit the gas limit.

Find the maximum or minimum

Track a running winner and update it when a new element beats the current best. Used for vote tallies, top bidder lookups, highest grade in a registry. Always specify tie breaking behaviour explicitly so the contract is deterministic.

Filter into a memory array

Walk the storage array and copy matching elements into a fresh memory array. Memory is cheaper to write than storage. Useful when an off chain caller wants only the active proposals, the unsold NFTs, or the holders above a threshold.

Swap and pop to remove an element

Replace the element at index i with the last element, then pop. This avoids the O(n) shift you would need with delete. Standard for keeping the registered voters list compact when someone unregisters.

Batch transfer or batch update

Walk a list of recipients and perform a small operation on each: send a token, increment a counter, set a flag. ERC1155 batch operations and many airdrop contracts use this shape. Cap the batch size in the function signature to avoid the unbounded loop bug.

Walk a delegation or chain

Use a while loop that follows pointers from one record to the next until it reaches a terminator. Vote delegation chains, position liquidation queues, and linked list patterns all use this shape. Always bound the walk with a safety counter that reverts after a maximum number of hops.

Section 05 · The big bug

Why unbounded loops are the most expensive Solidity mistake

If you remember one thing from this guide, remember this: a loop whose bound is controlled by users will eventually exceed the block gas limit, at which point every call to the function reverts and the contract logic becomes permanently broken.

Diagram showing the unbounded loop denial of service bug: as an array grows from 100 to 100,000 elements the gas cost crosses the block gas limit and the function becomes uncallable.
The classic timeline. Day 1 the contract works. Day 90 the function is slow. Day 180 the function is uncallable forever.

The pathology is always the same. A function loops over an array. The array grows over time as users register, claim, or stake. The gas cost grows linearly with the array length. At some point the cost exceeds the block gas limit (currently 30 million on Ethereum mainnet), and from that moment every transaction calling the function reverts before it can finish. There is no upgrade path that does not involve migrating state to a new contract.

solidity
// DO NOT WRITE THIS in production.
//
// distributeRewards loops over every holder. The contract works fine
// at 100 holders. At 100 000 holders the function becomes uncallable
// forever and the rewards locked in the contract are stuck.
function distributeRewards() external {
    uint256 perUser = address(this).balance / holders.length;
    for (uint256 i; i < holders.length; ++i) {
        payable(holders[i]).transfer(perUser);
    }
}

User controlled array length

Any array that grows whenever a user calls a public function is a candidate for this bug. Voter registries, holder lists, NFT mint trackers, leaderboards. If a malicious actor can pad the array with fake entries, the bug is even easier to trigger.

External calls inside the loop

transfer, call, and any external interaction inside a loop multiplies the per iteration cost by the cost of the called function. An expensive external call combined with a long array hits the gas limit fast. It is also a reentrancy surface.

Reverting on a single failure

A loop that reverts the entire transaction when one element fails is brittle. One bad recipient or one rejected transfer takes down the whole batch. Either skip and emit, or pay each recipient through a pull pattern instead.

Section 06 · The safe alternative

Two fixes: pagination and pull over push

When an unbounded loop becomes a risk, you have two well known workarounds. They cover almost every real case.

The first fix is pagination. Instead of one function that walks the entire array, expose a function that walks a window. The caller passes the start index and the count, the function processes that slice, and a separate state variable tracks the next start. This works well for one time operations like distributing a fixed reward pool.

solidity
uint256 public nextRecipient;

function distributeBatch(uint256 batchSize) external {
    uint256 end = nextRecipient + batchSize;
    if (end > holders.length) end = holders.length;
    for (uint256 i = nextRecipient; i < end; ++i) {
        payable(holders[i]).transfer(rewardPerUser);
    }
    nextRecipient = end;
}

function distributionDone() external view returns (bool) {
    return nextRecipient == holders.length;
}

The second fix, almost always preferred, is pull over push. Track per user balances in a mapping and let each user pull their own share when they want it. This shifts the gas cost from the protocol to each user, eliminates the loop entirely, and removes the reentrancy surface created by external calls inside a batch.

solidity
mapping(address => uint256) public claimable;

function recordReward(address user, uint256 amount) external onlyAuthorized {
    claimable[user] += amount;
}

function claim() external {
    uint256 amount = claimable[msg.sender];
    require(amount > 0, "nothing to claim");
    claimable[msg.sender] = 0;        // effects before interactions
    payable(msg.sender).transfer(amount);
    emit Claimed(msg.sender, amount);
}

The pull pattern is exactly the mapping with struct shape from the companion piece. You replace one expensive batch loop with one O(1) write per user during accrual, and one O(1) withdraw per user during claim. The total work done across all users is the same. The difference is that no single transaction has to do all of it.

Section 07 · The third option

When the right answer is to not loop on chain at all

Some computations are simply too expensive to do on chain. The fix is to compute the result off chain, then post just the answer.

If your loop produces a single number — a Merkle root, a sum, an aggregate score — you can compute that number off chain and submit it on chain along with a proof. The contract verifies the proof in constant time and stores the result. This is how every serious airdrop is structured in 2026.

solidity
bytes32 public merkleRoot;

function setRoot(bytes32 newRoot) external onlyOwner {
    merkleRoot = newRoot;
}

function claim(uint256 amount, bytes32[] calldata proof) external {
    bytes32 leaf = keccak256(abi.encodePacked(msg.sender, amount));
    require(verify(proof, merkleRoot, leaf), "bad proof");
    require(!claimed[msg.sender], "already claimed");
    claimed[msg.sender] = true;
    token.transfer(msg.sender, amount);
}

Off chain you build the full holder list and compute the Merkle root. On chain you store one bytes32. Each user submits a proof of their entry, the contract verifies it in O(log n) hashes regardless of total participants, and the transfer happens. A holder list of one million users is no more expensive on chain than a list of one hundred.

Three rules for choosing the strategy

If the loop body is one cheap step and the array bound is small and fixed, write the loop on chain. If the loop body is small and the array grows over time, paginate or switch to pull over push. If the loop produces an aggregate over a large user controlled set, compute off chain and verify a proof on chain. Get this choice right and the bug class disappears entirely.

FAQ

Frequently asked questions

How do I loop through an array in Solidity?

Use a for loop with a uint256 counter. Cache the array length in a local variable before the loop, compare the counter against the local, and use the prefix increment ++i. Inside the body, access the element at index i. The pattern is for (uint256 i; i < len; ++i) { ... arr[i] ... }.

How big can an array be before the loop runs out of gas?

It depends on the body. A loop that does a single arithmetic add can iterate around 100 000 elements before hitting the 30 million block gas limit on Ethereum mainnet. A loop that issues an external transfer per iteration runs out at around 1 000 elements. Test on a fork before relying on a specific number — the safe approach is to never let an unbounded user controlled loop ship to production at all.

What is the difference between for, while, and do while in Solidity?

for combines the counter, condition, and increment into one line. It is the most common loop in Solidity because most loops walk an array of known length. while runs while a condition holds and is used when the iteration count is not known up front. do while runs the body at least once before checking the condition. for and while cover almost every real case in Solidity; do while is rare.

Why is iterating an array on chain dangerous?

Every iteration costs gas and the total gas spent in a transaction is capped by the block gas limit. If the array grows past a threshold the function exceeds the cap and reverts on every call. The function effectively becomes uncallable forever. This bug is known as the unbounded loop denial of service and has cost real protocols real money.

How do I avoid the unbounded loop bug?

Three options. First, paginate the loop and have callers process the array in chunks. Second, switch to a pull pattern where each user claims their own share, eliminating the loop entirely. Third, compute the aggregate off chain and submit a Merkle proof or a signed result for verification on chain. The pull pattern handles most real cases.

Can I use forEach or map in Solidity?

No. Solidity does not have higher order array functions. It supports only for, while, and do while. There is no map, filter, reduce, or forEach. If you find yourself wanting one of those, write the equivalent for loop manually or compute the result off chain and post the answer.

Is unchecked safe inside a loop counter?

Yes, when the counter is bounded by the array length. A uint256 counter walking an array of any practical size cannot overflow because the array cannot be 2 to the 256 entries long. Wrapping the increment in unchecked { ++i; } skips the overflow check at the end of every iteration and saves around 30 to 60 gas per iteration.

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 →