🌱 Beginner ⏱️ 30 min

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.

📖 Arrays in Smart Contracts

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

📊 Array Types in Solidity
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

💻 Memory Arrays — Declaration and Limitations
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:

💣 Full Unbounded Loop Exploit and Fix
// 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:

🗑️ Deleting Elements Correctly
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

📝 bytes vs string vs bytes32
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 vs memory for Array Parameters
// 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 memory
📖 calldata vs memory Array Parameters

For 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.

💡 Tip: Use bytes32 Instead of string for Short Labels

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

BugExampleConsequenceFix
Off-by-one in loopi <= arr.lengthIndex out of bounds panicUse i < arr.length
Modify while iteratingswap-and-pop inside loopSkips element after popIterate backward or collect indices
delete leaves zero gapdelete arr[i]Zero address in array, length unchangedUse swap-and-pop
Push from memory contextAssigning memory array to storageCompile error or no effectUse storage reference and push()
Unbounded growthNo cap on push()DoS via block gas limitCap size or use pull-payment
Array length SLOAD in looparr.length in conditionExtra SLOAD each iterationCache: uint len = arr.length
⚠️ Modifying an Array While Iterating It

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.

✏️ Try It — Safe Pagination Pattern
// 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 calldata for 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.length in 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.