Why data location matters

Solidity is not just a language for expressing logic; it is also a language for managing state across transaction boundaries. Every time you read or write data, the EVM pays a different cost depending on where that data lives.

At a high level:

  • storage is persistent contract state
  • memory is temporary, mutable working space
  • calldata is read-only input data supplied to external functions

Understanding these distinctions helps you write contracts that are cheaper, safer, and easier to reason about.

The three data locations

storage

storage is where contract state variables live permanently. Data stored here remains on-chain between transactions.

Use storage for:

  • balances
  • ownership records
  • configuration values
  • mappings and arrays that must persist

Example:

pragma solidity ^0.8.24;

contract Vault {
    mapping(address => uint256) public deposits;

    function deposit() external payable {
        deposits[msg.sender] += msg.value;
    }
}

Here, deposits is in storage because the contract must remember balances after the transaction ends.

memory

memory is temporary and exists only during a function call. It is erased when execution ends.

Use memory for:

  • intermediate calculations
  • temporary arrays and structs
  • copied data you need to modify locally

Example:

pragma solidity ^0.8.24;

contract Example {
    function sum(uint256[] memory values) external pure returns (uint256 total) {
        for (uint256 i = 0; i < values.length; i++) {
            total += values[i];
        }
    }
}

The array is copied into memory, where the function can read it and compute a result.

calldata

calldata is read-only input data for external functions. It is cheaper than copying into memory because Solidity can read directly from the transaction input.

Use calldata for:

  • external function parameters
  • large arrays or strings that do not need modification
  • gas-sensitive read-only processing

Example:

pragma solidity ^0.8.24;

contract Registry {
    function registerUsers(address[] calldata users) external pure returns (uint256) {
        return users.length;
    }
}

Because the function only reads the array, calldata is the best choice.

When to use each location

The choice is often straightforward once you ask one question: does the data need to persist after the call?

Data locationLifetimeMutabilityTypical use
storagePersistentMutableContract state, mappings, arrays, structs
memoryFunction call onlyMutableTemporary copies, local arrays, return values
calldataFunction call onlyRead-onlyExternal function inputs, large read-only arguments

A useful rule of thumb:

  • If the contract must remember it, use storage
  • If the function needs to modify it temporarily, use memory
  • If the function only needs to inspect input, use calldata

Practical differences in behavior

Storage variables are references

When you assign a storage struct or array to a local variable, you are usually creating a reference, not a copy.

pragma solidity ^0.8.24;

contract TodoList {
    struct Task {
        string text;
        bool done;
    }

    Task[] public tasks;

    function markDone(uint256 index) external {
        Task storage task = tasks[index];
        task.done = true;
    }
}

task points directly to the array element in storage. Updating task.done updates contract state.

Memory variables are copies

If you copy a storage value into memory, changes to the memory copy do not affect state unless you write the result back explicitly.

pragma solidity ^0.8.24;

contract TodoList {
    struct Task {
        string text;
        bool done;
    }

    Task[] public tasks;

    function previewDone(uint256 index) external view returns (Task memory) {
        Task memory task = tasks[index];
        task.done = true;
        return task;
    }
}

This function returns a modified copy, but the stored task remains unchanged.

Calldata is read-only

You cannot modify calldata directly.

pragma solidity ^0.8.24;

contract Demo {
    function bad(uint256[] calldata values) external pure {
        // values[0] = 1; // not allowed
    }
}

If you need to mutate the data, copy it into memory first.

Gas implications in real contracts

The most practical reason to care about data location is gas cost.

calldata is usually cheaper for external inputs

For large arrays, strings, or bytes data, calldata avoids an unnecessary copy into memory.

Consider a batch-processing function:

pragma solidity ^0.8.24;

contract BatchProcessor {
    function process(address[] calldata recipients) external pure returns (uint256 count) {
        for (uint256 i = 0; i < recipients.length; i++) {
            if (recipients[i] != address(0)) {
                count++;
            }
        }
    }
}

If this parameter were memory, Solidity would first allocate and copy the entire array. With calldata, the function reads the input directly.

Storage reads and writes are expensive

Reading from storage is much more expensive than reading from memory or calldata, and writing to storage is more expensive still. This is why common optimization patterns include:

  • caching storage values in local variables
  • minimizing repeated storage reads inside loops
  • using memory for temporary aggregation

Example:

pragma solidity ^0.8.24;

contract Counter {
    uint256 public total;

    function incrementMany(uint256 n) external {
        uint256 current = total;
        for (uint256 i = 0; i < n; i++) {
            current++;
        }
        total = current;
    }
}

This pattern reduces repeated storage access by reading once and writing once.

Common patterns and best practices

Prefer calldata for external read-only parameters

If an external function only inspects input data, declare parameters as calldata.

Good:

function verify(address[] calldata signers) external pure returns (bool) {
    return signers.length > 0;
}

Less efficient:

function verify(address[] memory signers) external pure returns (bool) {
    return signers.length > 0;
}

The second version forces a copy.

Use memory for temporary transformations

If you need to sort, filter, or otherwise transform data, use memory.

pragma solidity ^0.8.24;

contract Transformer {
    function firstTwo(uint256[] calldata values) external pure returns (uint256[] memory out) {
        require(values.length >= 2, "too short");
        out = new uint256[](2);
        out[0] = values[0];
        out[1] = values[1];
    }
}

The input stays in calldata, while the output is built in memory.

Use storage references carefully

Storage references are powerful, but they can also be dangerous if you accidentally mutate state in a helper function.

pragma solidity ^0.8.24;

contract Config {
    struct Settings {
        uint256 feeBps;
        bool paused;
    }

    Settings public settings;

    function updateFee(uint256 newFee) external {
        Settings storage s = settings;
        s.feeBps = newFee;
    }
}

This is efficient, but it means any mutation to s directly changes contract state. Be explicit when using storage references so the code remains readable.

Copying between locations

Solidity allows movement between locations, but not all conversions are equally cheap or allowed.

Storage to memory

You can copy a storage array or struct into memory for temporary use.

pragma solidity ^0.8.24;

contract Snapshot {
    struct Position {
        uint256 amount;
        uint256 openedAt;
    }

    Position public position;

    function getSnapshot() external view returns (Position memory) {
        return position;
    }
}

This is useful when you need to return a value or work with a local copy.

Calldata to memory

Sometimes you must copy calldata into memory, especially if a function needs to modify the data.

pragma solidity ^0.8.24;

contract Copier {
    function duplicate(bytes calldata input) external pure returns (bytes memory output) {
        output = input;
    }
}

This is a full copy, so use it only when necessary.

Memory to storage

Writing memory data back to storage is common when updating persistent state.

pragma solidity ^0.8.24;

contract ProfileBook {
    struct Profile {
        string name;
        uint256 reputation;
    }

    mapping(address => Profile) public profiles;

    function setProfile(string calldata name, uint256 reputation) external {
        profiles[msg.sender] = Profile(name, reputation);
    }
}

Here, the calldata string is used to construct a new storage value.

A realistic example: batch updates

Suppose you are building a contract that updates multiple user balances in one transaction.

pragma solidity ^0.8.24;

contract Rewards {
    mapping(address => uint256) public points;

    function addPoints(address[] calldata users, uint256[] calldata amounts) external {
        require(users.length == amounts.length, "length mismatch");

        for (uint256 i = 0; i < users.length; i++) {
            points[users[i]] += amounts[i];
        }
    }
}

Why this design works well:

  • both arrays are calldata, so no unnecessary copies are made
  • the function updates storage only where persistence is required
  • the loop reads input directly and writes state only once per user

If the function instead accepted memory arrays, every call would pay extra gas for copying the inputs.

Choosing the right location for function signatures

The function type determines what data locations are available.

  • external functions can accept calldata
  • public functions often use memory for internal compatibility
  • internal functions can use memory or storage references depending on the data source

A common pattern is to expose an external function with calldata, then delegate to an internal helper that works with memory if needed.

pragma solidity ^0.8.24;

contract Router {
    function submit(address[] calldata users) external pure returns (uint256) {
        return _count(users);
    }

    function _count(address[] calldata users) internal pure returns (uint256) {
        return users.length;
    }
}

This keeps the external interface efficient while preserving internal clarity.

Pitfalls to avoid

Accidentally copying large data

A large array passed as memory can become expensive. If the function does not mutate it, prefer calldata.

Confusing references with copies

A storage reference modifies state immediately. A memory copy does not. Be explicit when assigning local variables.

Returning storage directly from internal logic

You cannot safely expose storage references as return values in the same way you can with memory. Return copies when needed.

Mutating state in helper functions unintentionally

If a helper receives a storage reference, it can change contract state. Document this clearly in the function name or comments.

Summary

Choosing between storage, memory, and calldata is a core Solidity skill.

  • Use storage for persistent on-chain state
  • Use memory for temporary mutable data
  • Use calldata for external read-only inputs, especially large ones

The best contracts minimize unnecessary copying, make state changes explicit, and use the cheapest data location that still preserves correctness.

Learn more with useful resources