Data Locations in Solidity: storage, memory, and calldata

Every variable in Solidity lives somewhere. That somewhere is called a data location, and Solidity gives you three: storage, memory, and calldata. Getting this right matters. The wrong location can silently break your contract by updating a copy instead of the real state, or waste gas on unnecessary data copying.

This post covers how each location works, when to use each one, and the specific rules that apply to state variables, function parameters, local variables, and return values.


The Three Locations

storage is persistent. It lives on the blockchain and survives between function calls. Every state variable in your contract lives in storage.

memory is temporary. It exists only while a function is executing. Once the function returns, memory is gone.

calldata is read-only. When someone calls an external function, the arguments arrive in calldata. You cannot modify calldata. You can only read from it.

The location you choose affects three things: gas cost, whether you can modify the data, and how long the data exists.


State Variables

State variables are declared at the contract level, outside of any function. They always live in storage. You never add a location keyword to a state variable.

contract TokenRegistry {
    uint256 public totalSupply;
    address public owner;
    string public name;
    uint256[] public allIds;
    mapping(address => uint256) public balances;

    struct Token {
        uint256 id;
        string name;
        address owner;
    }
    Token[] public tokens;
}

Adding a location keyword to a state variable is a compile error:

// This will not compile
uint256 memory counter;

State variables are storage by definition. The keyword is not allowed and not needed.


Value Types vs Reference Types

Before looking at function variables and parameters, you need to understand one distinction: value types vs reference types.

Value types include uint, int, bool, address, bytes1 through bytes32, and enums. When you assign a value type, Solidity copies it. There is no "reference" to the original. Because of this, value types never need a location keyword.

Reference types include arrays, structs, strings, and bytes. When you assign a reference type, it can either point to the original or be a new copy, depending on the location you specify. This is where the choice matters.


Local Variables Inside Functions

When you declare a local variable of a reference type inside a function, you must choose storage or memory.

storage gives you a reference

Using storage creates a pointer to a state variable. Changes through that pointer modify the actual state.

contract Example {
    uint256[] public numbers;

    function addToState() public {
        // storageRef points to the state variable 'numbers'
        uint256[] storage storageRef = numbers;
        storageRef.push(42); // This modifies 'numbers' in state
    }
}

memory gives you a copy

Using memory creates a temporary copy of the data. Changes to it do not touch the state variable.

contract Example {
    uint256[] public numbers;

    function readOnly() public view {
        // memoryCopy is a separate copy, changes here go nowhere
        uint256[] memory memoryCopy = numbers;
        memoryCopy[0] = 999; // Only modifies the local copy
    }
}

The same rule applies to structs:

contract Example {
    struct Person {
        string name;
        uint256 age;
    }
    Person public person;

    function updateAge() public {
        // Wrong: creates a copy, state is unchanged
        Person memory copy = person;
        copy.age = 30;

        // Correct: storage reference, state is updated
        Person storage ref = person;
        ref.age = 30;
    }
}

Using memory when you meant storage is a silent bug. The compiler will not warn you. The function will run, the copy will be modified, and the state variable will remain unchanged. Always double-check which one you need before writing a local reference type variable.

Creating new data in memory

When you create a new array or struct inside a function, it always goes in memory:

function buildArray() public pure returns (uint256[] memory) {
    uint256[] memory result = new uint256[](3);
    result[0] = 10;
    result[1] = 20;
    result[2] = 30;
    return result;
}

Value types in local variables need no location keyword:

function calculate() public view returns (uint256) {
    uint256 total = 0;   // No keyword needed
    bool found = false;  // No keyword needed
    address sender = msg.sender; // No keyword needed
    return total;
}

Function Parameters

The right location for a function parameter depends on the function visibility and whether you need to modify the data.

External functions: use calldata

External functions can only be called from outside the contract. For reference type parameters, calldata is the most efficient choice. The data arrives in calldata already, so using calldata means no copying.

contract TokenRegistry {
    struct Token {
        uint256 id;
        string name;
        address owner;
    }

    function registerTokens(Token[] calldata newTokens) external {
        for (uint256 i = 0; i < newTokens.length; i++) {
            // Reading from calldata is cheap
            // Cannot modify newTokens[i] directly
        }
    }

    function getNameLength(string calldata name) external pure returns (uint256) {
        return bytes(name).length;
    }
}

Because calldata is read-only, you cannot modify the parameter. If you need to modify the data inside the function, copy it to memory first.

Public functions: calldata or memory

Public functions can be called externally or from within the contract. Both calldata and memory are valid for reference type parameters.

calldata costs less gas when called externally. memory is required when the function is called internally, since internal calls cannot pass calldata directly.

contract Example {
    // Works, but costs more gas when called externally
    function withMemory(uint256[] memory data) public pure returns (uint256) {
        return data.length;
    }

    // More efficient for external callers
    function withCalldata(uint256[] calldata data) public pure returns (uint256) {
        return data.length;
    }
}

For public functions that are called mostly externally, prefer calldata. For functions that are also called internally, memory is the safer default.

Internal and private functions: memory or storage

Internal and private functions cannot be called from outside the contract. You can pass either a memory copy or a storage reference.

contract Example {
    uint256[] public numbers;

    // Internal function that modifies state via storage reference
    function addToArray(uint256[] storage arr, uint256 value) internal {
        arr.push(value);
    }

    // Internal function that reads from a memory array
    function sumArray(uint256[] memory arr) internal pure returns (uint256) {
        uint256 total = 0;
        for (uint256 i = 0; i < arr.length; i++) {
            total += arr[i];
        }
        return total;
    }

    function run() public {
        addToArray(numbers, 100); // Passes storage reference

        uint256[] memory temp = new uint256[](3);
        temp[0] = 1;
        temp[1] = 2;
        temp[2] = 3;
        uint256 result = sumArray(temp); // Passes memory array
    }
}

Value type parameters never take a location keyword, regardless of function visibility:

// Correct
function transfer(uint256 amount, address to, bool verified) public { }

// Compile error
// function transfer(uint256 memory amount) public { }

Return Values

Value types returned from functions need no location keyword. Reference types must use memory or calldata. You cannot return a storage reference.

Returning reference types from state

When a function returns an array or struct from state, Solidity copies it into memory first:

contract Example {
    uint256[] public numbers;
    string public text;

    function getNumbers() public view returns (uint256[] memory) {
        return numbers; // Copied from storage to memory
    }

    function getText() public view returns (string memory) {
        return text;
    }
}

Returning newly created data

When you create data inside a function and return it, it must be memory:

function buildRange(uint256 n) public pure returns (uint256[] memory) {
    uint256[] memory result = new uint256[](n);
    for (uint256 i = 0; i < n; i++) {
        result[i] = i;
    }
    return result;
}

Returning calldata directly

If a function receives a calldata parameter and you want to return it unchanged, you can return calldata directly. This avoids copying and saves gas:

function passThrough(string calldata input) external pure returns (string calldata) {
    return input; // No copy, very efficient
}

This only works when you return the same calldata without modification.


A Full Example

Here is a contract that uses all three locations together, showing how they interact:

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

contract TokenRegistry {
    // State variables: always storage, no keyword
    address public owner;
    uint256 public totalCount;

    struct Token {
        uint256 id;
        string name;
        address tokenOwner;
    }
    Token[] public tokens;
    mapping(address => uint256[]) public ownerTokenIds;

    constructor() {
        owner = msg.sender; // value type, no keyword
        totalCount = 0;
    }

    // External: calldata for reference types
    function batchRegister(Token[] calldata newTokens) external {
        require(msg.sender == owner, "Not owner");

        for (uint256 i = 0; i < newTokens.length; i++) {
            tokens.push(newTokens[i]);
            ownerTokenIds[newTokens[i].tokenOwner].push(newTokens[i].id);
            totalCount++;
        }
    }

    // Public: returns memory copy from state
    function getToken(uint256 index) public view returns (Token memory) {
        return tokens[index];
    }

    // Internal: storage reference to modify state directly
    function updateName(uint256 index, string memory newName) internal {
        Token storage token = tokens[index]; // storage reference
        token.name = newName;               // modifies state
    }

    // Internal: pure calculation using memory
    function sumIds(uint256[] memory ids) internal pure returns (uint256) {
        uint256 total = 0;
        for (uint256 i = 0; i < ids.length; i++) {
            total += ids[i];
        }
        return total;
    }

    // Public: mixes calldata input with storage and memory
    function processAndUpdate(
        uint256[] calldata inputIds,
        uint256 updateIndex,
        string memory newName
    ) public returns (uint256) {
        // Work with storage
        uint256[] storage ownerIds = ownerTokenIds[msg.sender];
        ownerIds.push(inputIds[0]); // modifies state

        // Work with memory
        uint256[] memory tempIds = new uint256[](inputIds.length);
        for (uint256 i = 0; i < inputIds.length; i++) {
            tempIds[i] = inputIds[i];
        }

        updateName(updateIndex, newName); // passes memory string to internal

        return sumIds(tempIds); // passes memory array
    }
}

Quick Reference

ContextValue typesReference types
State variablesNo keyword (always storage)No keyword (always storage)
Local variable pointing to stateNo keywordstorage
Local variable for temporary useNo keywordmemory
External function parameterNo keywordcalldata (preferred)
Public function parameterNo keywordcalldata or memory
Internal/private function parameterNo keywordmemory or storage
Return valueNo keywordmemory or calldata

The pattern is consistent once you see it. Value types are always copied, so location never applies to them. Reference types need a location whenever they appear as local variables, parameters, or return values, because Solidity needs to know whether to create a reference or a copy.

Use storage when you need to modify state. Use calldata for external inputs you will not modify. Use memory for everything else.