Arrays & Iteration
Arrays are the natural way to represent ordered collections in Solidity, but they come with traps that have caused major exploits. Gas costs, bounded growth, storage vs memory semantics, and safe deletion patterns are all topics that appear in real audits. This lesson gives you a complete and security-aware picture of arrays in Solidity.
Unlike in traditional programming, arrays in Solidity have significant gas implications. Storing data in a storage array costs 22,100 gas per new element (cold SSTORE). Reading each element in a loop costs 2,100 gas (cold SLOAD). These costs make unbounded arrays — arrays that grow without limit — one of the most common denial-of-service attack vectors in deployed contracts.
Fixed-Size Arrays vs Dynamic Arrays
contract ArrayTypes {
// FIXED-SIZE: size is part of the type, known at compile time
uint256[3] public votes; // always 3 elements
address[5] public committee; // always 5 elements
// DYNAMIC: size can grow/shrink, stored with a length slot
uint256[] public proposals; // starts empty
address[] public members; // grows with push()
function addMember(address m) external {
members.push(m); // appends, updates length
}
function removeLast() external {
members.pop(); // removes last, refunds gas
}
function getLength() external view returns (uint256) {
return members.length; // length is a property
}
function getAt(uint256 i) external view returns (address) {
require(i < members.length, "out of bounds");
return members[i]; // access by index
}
}Memory Arrays: Fixed Size, No Push
function buildArray(uint256 n) external pure returns (uint256[] memory) {
// Memory arrays MUST declare size at creation — cannot use push()
uint256[] memory result = new uint256[](n);
for (uint256 i = 0; i < n; i++) {
result[i] = i * i; // assign by index
}
return result;
// result is NOT persisted — lives only during this call
}
// TRAP: you cannot push() to a memory array
uint256[] memory arr = new uint256[](5);
// arr.push(1); ← COMPILE ERROR
// TRAP: assigning a storage array to memory makes a copy
function copyExample() external view {
uint256[] memory copy = members; // copies entire storage array to memory
// Expensive! Gas = 2,100 * members.length for SLOADs
// Only do this if you need the whole array in memory at once
}The Unbounded Loop DoS Attack — Full Detail
This is one of the most commonly found vulnerabilities in smart contract audits. The attack is simple: grow the array until the iteration function exhausts the block gas limit and reverts, permanently disabling protocol functionality:
// VULNERABLE: staking contract that iterates all stakers
contract VulnerableStaking {
address[] public stakers; // anyone can append
mapping(address => uint256) public staked;
function stake() external payable {
stakers.push(msg.sender); // no cap on stakers!
staked[msg.sender] += msg.value;
}
// Attack: call stake() 10,000 times from different addresses
// Then this function hits the 30M gas block limit and reverts forever
function distributeRewards(uint256 rewardPool) external {
uint256 total = address(this).balance;
for (uint256 i = 0; i < stakers.length; i++) {
// SLOAD (stakers[i]) + SLOAD (staked[x]) + SSTORE (rewards[x])
rewards[stakers[i]] += rewardPool * staked[stakers[i]] / total;
}
}
}
// SAFE: pull payment — users claim individually
contract SafeStaking {
mapping(address => uint256) public staked;
uint256 public rewardPerToken; // accumulates over time
mapping(address => uint256) public rewardDebt;
function claim() external {
uint256 pending = staked[msg.sender] * rewardPerToken - rewardDebt[msg.sender];
rewardDebt[msg.sender] = staked[msg.sender] * rewardPerToken;
payable(msg.sender).transfer(pending);
}
}Safe Deletion: swap-and-pop
Deleting an element from the middle of a dynamic array using delete arr[i] leaves a zero gap — it does not remove the element or shift others. The correct pattern for unordered arrays is swap-and-pop:
contract SafeDeletion {
address[] public items;
// BAD: leaves a zero address at position i
function badRemove(uint256 index) external {
delete items[index]; // items[index] = address(0), length unchanged
// Array now has a zero in the middle — corrupted!
}
// GOOD: swap-and-pop — O(1), maintains no-gap invariant
function swapAndPop(uint256 index) external {
require(index < items.length, "out of bounds");
items[index] = items[items.length - 1]; // copy last to index
items.pop(); // remove last
// Order is NOT preserved — use only for unordered arrays
}
// For ORDER-PRESERVING deletion (expensive — O(n) shifts):
function orderedRemove(uint256 index) external {
for (uint256 i = index; i < items.length - 1; i++) {
items[i] = items[i + 1]; // shift left
}
items.pop();
// Avoid this for large arrays — O(n) gas cost
}
}bytes and string: Byte Array Relatives
contract ByteArrays {
// bytes: dynamic byte array — like byte[] but more efficient
bytes public rawData;
// string: like bytes but for UTF-8 text — no index access
string public name;
// bytes32: FIXED 32-byte array — cheapest for short data
bytes32 public symbol;
// Converting between bytes and string:
function setName(string calldata n) external {
require(bytes(n).length <= 64, "name too long"); // length check
name = n;
}
// Concatenation — Solidity 0.8.12+
function concat(string memory a, string memory b) external pure
returns (string memory) {
return string.concat(a, b); // native concat (0.8.12+)
}
// Old way (before 0.8.12):
// return string(abi.encodePacked(a, b));
}Calldata Arrays: Gas-Efficient Input
// calldata: read-only, stays in calldata region (very cheap)
function processCalldata(uint256[] calldata ids) external {
// No copy to memory — reads directly from calldata
// Gas: 4 gas/zero byte, 16 gas/non-zero byte in calldata
for (uint256 i = 0; i < ids.length; i++) {
process(ids[i]); // CALLDATALOAD — cheap
}
}
// memory: copied from calldata first (more expensive)
function processMemory(uint256[] memory ids) external {
// Array is copied from calldata to memory on function entry
// Use only if you need to MODIFY the array during processing
ids[0] = 999; // allowed in memory, not in calldata
}
// Rule: use calldata for external function array inputs unless
// you need to modify the array, then use memoryFor external functions, always prefer calldata over memory for array parameters you only need to read. When an array is marked memory, Solidity copies the entire array from calldata into EVM memory at function entry — that costs gas proportional to the array size. With calldata, the data stays in the calldata region and is read directly, with no copy cost.
If your string values are always 32 bytes or shorter (names, symbols, roles), use bytes32 instead of string. A bytes32 value is stored in a single storage slot and requires no length prefix or dynamic allocation. A string requires an SLOAD for the length, plus additional SLOADs for each 32-byte chunk of content. For ERC20 symbol and name, many gas-optimized tokens store them as bytes32 and convert to string only on return.
Common Array Bugs
| Bug | Example | Consequence | Fix |
|---|---|---|---|
| Off-by-one in loop | i <= arr.length | Index out of bounds panic | Use i < arr.length |
| Modify while iterating | swap-and-pop inside loop | Skips element after pop | Iterate backward or collect indices |
| delete leaves zero gap | delete arr[i] | Zero address in array, length unchanged | Use swap-and-pop |
| Push from memory context | Assigning memory array to storage | Compile error or no effect | Use storage reference and push() |
| Unbounded growth | No cap on push() | DoS via block gas limit | Cap size or use pull-payment |
| Array length SLOAD in loop | arr.length in condition | Extra SLOAD each iteration | Cache: uint len = arr.length |
Calling swapAndPop(i) inside a forward loop causes the element that was moved into position i to be skipped, because the loop increments i immediately after the swap. If you need to remove elements while iterating, iterate backward (from length - 1 down to 0) so that removals don't affect unvisited indices.
// Safe iteration: paginate large arrays to avoid gas limit
contract PaginatedRewards {
address[] public stakers;
uint256 public constant PAGE_SIZE = 50;
// Process stakers in pages of 50 — caller loops over pages off-chain
function distributeRewardsPage(uint256 startIndex) external {
uint256 end = startIndex + PAGE_SIZE;
if (end > stakers.length) end = stakers.length;
for (uint256 i = startIndex; i < end; i++) {
// bounded — at most PAGE_SIZE iterations per call
rewards[stakers[i]] += calcReward(stakers[i]);
}
}
// Off-chain script calls this in a loop:
// for (let i = 0; i < total; i += PAGE_SIZE) {
// await contract.distributeRewardsPage(i);
// }
}Key Takeaways
- Dynamic storage arrays cost 22,100 gas per push (cold SSTORE) and 2,100 per read (cold SLOAD) — growth without bounds is a DoS vector.
- Memory arrays must be given a fixed size at creation and cannot use
push()— they are temporary and not persisted. - Use
calldatafor external function array parameters when you only need to read the data — it avoids a memory copy and saves gas. - Deleting a middle element with
delete arr[i]leaves a zero gap. Use swap-and-pop for unordered arrays, or shift elements for ordered arrays. - Cache
arr.lengthin a local variable before a loop to avoid paying SLOAD costs on every iteration. - If iteration over large arrays is required, implement a pagination pattern with a fixed page size rather than iterating the full array in one transaction.