BlockchainSolidity12 min readUpdated

Solidity Arrays From Scratch: A Practical Guide

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

Cover illustration for: Solidity Arrays From Scratch: A Practical Guide

Section 01 · From scratch

What is an array in Solidity?

An array is a single variable that stores many values of the same type, laid out in order. Each value sits at a numbered position called an index, starting at zero. If you have ever used a list in Python or an array in JavaScript, the mental model is the same — only the rules around growth and gas are stricter.

Quick answer

What is an array? A Solidity array is an ordered, zero indexed collection of values that all share one type. The type is part of the declaration — uint256[] holds only uint256 values, address[] holds only addresses. You read a value by its index and, on dynamic storage arrays, you can append with .push() or remove the last element with .pop().

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract HelloArray {
    // Three numbers, fixed length, declared with initial values.
    uint256[3] public initialScores = [uint256(10), 20, 30];

    // An empty list of addresses that can grow over time.
    address[] public players;
}

The array initialScores always holds three numbers. You can change a value at a position, but you cannot add a fourth slot or remove one. The array players starts empty. Every call to players.push(someAddress) appends a new entry and the array grows by one.

Diagram comparing a fixed length array with five indexed cells against a dynamic array with three filled cells and one push slot.
Anatomy of a Solidity array. Fixed arrays carry their size in the type. Dynamic arrays grow with .push().

Section 02 · Declaring arrays

Five ways to declare an array

Arrays show up in five common shapes. Knowing each one lets you read most production contracts comfortably.

solidity
// 1. Fixed length, declared with initial values.
uint256[3] public scores = [uint256(10), 20, 30];

// 2. Fixed length, default zeroed values.
bool[5] public flags;            // [false, false, false, false, false]

// 3. Dynamic, starts empty, grows with push.
address[] public players;

// 4. Dynamic array of structs.
struct Bid { address bidder; uint256 amount; }
Bid[] public bids;

// 5. Two-dimensional array (array of arrays).
uint256[][] public matrix;       // each row can have its own length

Two small surprises catch new Solidity developers. The first is the cast in [uint256(10), 20, 30]: without it the literal [10, 20, 30] is interpreted as uint8[3], which is not assignable to uint256[3]. The second is the difference between uint256[3][] and uint256[][3] — Solidity reads the dimensions in the opposite order from C, so the inner type is the one that comes after the variable name when reading left to right.

Section 03 · The full API

Indexing, length, push, pop, delete

The Solidity array API is intentionally small. Five operations cover almost every real contract.

Solidity array operations at a glance
OperationSyntaxWorks onNote
Read by indexa[i]all arraysreverts if i >= length
Write by indexa[i] = xfixed and dynamic, mutablemust be in range
Lengtha.lengthall arraysfree read, never throws
Appenda.push(x)dynamic storage onlygrows the array by one
Remove lasta.pop()dynamic storage onlyrefunds some gas
Reset slotdelete a[i]all mutable arraysdoes not shrink, only zeroes
solidity
uint256[] public list;

list.push(7);                  // list = [7]
list.push(11);                 // list = [7, 11]
list.push(13);                 // list = [7, 11, 13]

uint256 first = list[0];       // 7
uint256 n     = list.length;   // 3

list.pop();                    // list = [7, 11], length = 2

delete list[0];                // list = [0, 11], length is STILL 2

What delete really does

delete sets the slot to the default value of its type — zero for numbers, false for bools, address(0) for addresses. It does not shift later elements down and it does not reduce length. To genuinely remove an item, use the swap and pop pattern below.

solidity
// Swap-and-pop: remove an element in O(1) without leaving a hole.
function removeAt(uint256 i) external {
    require(i < list.length, "out of bounds");
    list[i] = list[list.length - 1];   // overwrite slot with last
    list.pop();                        // shrink by one
}

Swap and pop trades order for speed. After the call the element that used to be last is now sitting where you removed something — the array is no longer in insertion order. If order genuinely matters (rare in smart contracts), you have to shift every later element down by one, which costs a lot of gas and is only safe for small arrays.

Section 04 · Data locations

storage, memory, and calldata

Arrays look the same in source code, but where they live decides what you can do with them and how much gas the call costs.

Three column comparison of storage memory and calldata data locations in Solidity with persistence and gas notes.
Same array, three different homes. Storage persists, memory is scratch space, calldata is the cheapest read only input.
solidity
// storage — declared at contract level, persists across transactions
address[] public allHolders;

// memory — fixed-size scratch space, cleared at end of the call
function topThree() external pure returns (uint256[] memory) {
    uint256[] memory out = new uint256[](3);
    out[0] = 1; out[1] = 2; out[2] = 3;
    return out;
}

// calldata — read-only input, cheapest of the three
function airdrop(address[] calldata recipients, uint256 amount) external {
    for (uint256 i = 0; i < recipients.length; i++) {
        // recipients[i] = address(0); ← compile error, calldata is read-only
        // do something with recipients[i]
    }
}

The rule of thumb that survives most code review feedback is to declare external function inputs as calldata whenever the function does not need to modify them. Use memory for working arrays inside a function. Reach for storage when you truly need state that lives between transactions.

Section 05 · Iterating safely

Loops and the unbounded loop bug

Loops over arrays are fine when the length is bounded by the contract. They are dangerous when the length depends on user input.

solidity
// Cache .length in a local — saves an SLOAD on every iteration.
uint256[] public values;

function sum() external view returns (uint256 total) {
    uint256 len = values.length;
    for (uint256 i = 0; i < len; ++i) {
        total += values[i];
    }
}

The pattern above is safe because values.length is controlled by the contract — only the contract decides when to push, so the array cannot grow without limit. The same loop becomes dangerous the moment users can call push directly.

solidity
// DANGEROUS — anyone can join, refundAll loops over everyone
address[] public participants;

function join() external { participants.push(msg.sender); }

function refundAll() external onlyOwner {
    for (uint256 i = 0; i < participants.length; i++) {
        payable(participants[i]).transfer(1 ether);
    }
    // attacker registers 10 000 throwaway addresses → refundAll runs out of
    // gas → refunds revert forever → contract is effectively bricked.
}

Pull over push

The fix that scales is to give each user a way to claim their own funds — store balances in a mapping and expose a withdraw() function. The contract never iterates the list, so growth in participants never breaks the contract. This is OpenZeppelin's PullPayment pattern and the standard for distributing DeFi rewards.

Section 06 · Worked example

A complete contract using arrays

A small but realistic contract that tracks a list of student scores. It demonstrates declaring, pushing, indexing, iterating, swap and pop removal, and exposing a getter for the full list.

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

contract ScoreBook {
    address public immutable owner;

    // Each score is one entry in this array.
    uint256[] private scores;

    // Event emitted each time a score is added.
    event ScoreAdded(uint256 indexed index, uint256 value);
    event ScoreRemoved(uint256 indexed index);

    constructor() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        require(msg.sender == owner, "not owner");
        _;
    }

    // 1. Append a score to the end.
    function addScore(uint256 value) external onlyOwner {
        scores.push(value);
        emit ScoreAdded(scores.length - 1, value);
    }

    // 2. Read a score by index.
    function getScore(uint256 i) external view returns (uint256) {
        require(i < scores.length, "out of bounds");
        return scores[i];
    }

    // 3. Read how many scores are stored.
    function totalScores() external view returns (uint256) {
        return scores.length;
    }

    // 4. Remove a score in O(1) by swapping with the last element.
    function removeScore(uint256 i) external onlyOwner {
        require(i < scores.length, "out of bounds");
        scores[i] = scores[scores.length - 1];
        scores.pop();
        emit ScoreRemoved(i);
    }

    // 5. Compute the average. Loop is bounded — only the owner can grow it.
    function averageScore() external view returns (uint256) {
        uint256 len = scores.length;
        require(len > 0, "no scores yet");
        uint256 total;
        for (uint256 i = 0; i < len; ++i) {
            total += scores[i];
        }
        return total / len;
    }

    // 6. Return a memory copy of every score for off-chain consumers.
    function allScores() external view returns (uint256[] memory) {
        return scores;
    }
}

Walk through it once and the entire array surface area is on the page. addScore uses push. getScore uses indexed read with a bounds check. removeScore uses swap and pop. averageScore caches the length and iterates. allScores returns the entire storage array as a uint256[] memory, which the EVM copies into the return data for the caller.

Section 07 · Real world use cases

Where arrays show up in production

Arrays are not always the right tool, but four patterns show up over and over in audited code.

Four cards showing real Solidity array use cases: whitelist, token holders, proposal queue, and Merkle proof input.
Four patterns ready for production. Each one uses arrays for a clear reason — order, enumeration, or proof input.

Whitelist for a sale

A small list of approved addresses kept on chain. Cheap to check membership when the list is short, and it survives across transactions because it lives in storage.

Token holder snapshot

When a project plans an airdrop, it sometimes records the holder set at a specific block in an address[] for later reference. Off chain indexers usually take over once the list grows.

Governance proposal queue

A DAO appends each proposal as a new struct in a Proposal[] array. The index becomes the proposal id and the array grows as governance activity accumulates.

Merkle proof input

An airdrop verifier accepts bytes32[] calldata proof so the user can claim their share. The array stays in calldata, which makes it the cheapest possible input shape.

Section 08 · Common pitfalls

Five mistakes beginners make

Forgetting that delete does not shrink

delete a[i] resets the slot to zero, but the array still has the same length. If you forget this, your loops keep visiting the empty slot and your tally is wrong.

Iterating user controlled lengths

Any loop whose bound depends on an array users can grow is a denial of service risk. Cap the number of iterations, paginate, or switch to a pull pattern.

Using storage where memory is enough

Reading a storage slot is hundreds of gas per access. If you only need a value once inside a function, copy it into a local memory variable first.

Returning huge arrays from view functions

A view that returns a 10 000-entry array works in tests and reverts in production wallets that cap eth_call response size. Paginate the return value instead.

Confusing array of arrays dimension order

uint256[3][] is a dynamic list of length three rows. uint256[][3] is a fixed three row matrix where each row is dynamic. Beginners flip them constantly.

Section 09 · Decision guide

When NOT to use an array

Arrays earn their keep when order matters or you genuinely need to enumerate the contents on chain. The moment neither holds, a mapping is almost always the better choice.

Quick decision: array or mapping?
You need…Best fitWhy
Lookup by known keymappingO(1) read, no iteration cost
Ordered list with insertion orderarrayindices give you the order
Membership check on a small listarrayfine up to ~50 entries
Membership check on a large listmapping(address => bool)scales without iteration cost
Enumerate every entry on chainarraymappings have no iteration
Both lookup and enumerationarray + mappingOpenZeppelin EnumerableSet

A surprising amount of production code uses both at the same time — an array of keys for occasional iteration, plus a mapping from key to struct for fast lookup. OpenZeppelin packages this combination as EnumerableSet, which is worth reading once even if you never use it directly.

Section 11 · FAQ

Frequently asked questions

What is an array in Solidity in simple words?

An array is a single variable that holds many values of the same type, stored in order. Each value lives at a numbered position called an index. Indexes start at zero, so the first value is at position zero, the second at position one, and so on.

What is the difference between fixed and dynamic arrays?

A fixed array bakes its size into the type — uint256[5] always holds exactly five values. A dynamic array writes empty brackets — uint256[] starts empty and can grow at runtime with .push(). Fixed arrays save a small amount of gas because the layout is known at compile time, but they cannot grow.

How do I add an element to a Solidity array?

On a dynamic storage array, call .push(value). The array length grows by one and the new value lives at the new last index. Fixed length arrays cannot be appended to — you can only overwrite an existing slot with a[i] = value.

How do I remove an element from a Solidity array?

On a dynamic storage array, .pop() removes the last element. To remove an element from any other position without leaving a hole, copy the last element into the slot you are removing and then call .pop(). This pattern is called swap and pop and runs in constant time but does not preserve order.

What does delete a[i] do in Solidity?

It resets the value at index i to the default value of the array's type — zero for numbers, false for bools, address(0) for addresses. It does not move later elements down and it does not change the array length. The slot still exists; it just holds the type's zero value.

When should I use storage, memory, or calldata for an array?

Use storage for state variables that must persist across transactions. Use memory for arrays you create inside a function and only need until the call ends. Use calldata for external function inputs you do not need to modify — it is the cheapest of the three because the data stays in the call's input region.

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 →