
Designing Safe and Predictable Solidity Loop Patterns
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
| Pattern | When to use | Tradeoff |
|---|---|---|
| Pull-based claims | Recipients can claim individually | More user interaction |
| Precompute state, then settle later | Large batches or complex logic | More storage |
| Internal accounting only | No immediate transfer needed | Requires 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.lengthin 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 shape | Best use case | Main risk |
|---|---|---|
for with fixed upper bound | Bounded batch work | Incorrect bound selection |
for over calldata array | User-submitted batches | Large calldata payloads |
for over storage array | Small on-chain collections | Gas blowups |
while loops | State-driven progress | Harder to prove termination |
| Nested loops | Small matrix-like data | Rapid 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:
- Can the loop grow without limit?
- If yes, redesign it as a batch or resumable process.
- Does the loop read from storage on every iteration?
- If yes, cache values where possible and keep the collection small.
- Does the loop make external calls?
- If yes, consider moving those calls out of the loop.
- Can the loop be safely repeated?
- If not, add guards to prevent double counting or duplicate state changes.
- Can the loop terminate unexpectedly due to gas?
- If yes, define a maximum batch size and test worst-case execution.
- 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.
