Storage vs Memory vs Calldata

Difficulty: Beginner

Understanding data locations in Solidity is one of the most important concepts for writing correct and gas-efficient smart contracts. The EVM provides four distinct areas where data can live: storage, memory, calldata, and the stack. Each has different persistence characteristics, gas costs, and access patterns. Choosing the wrong data location is a common source of both bugs and excessive gas costs.

Storage is the permanent, persistent data store for each contract. It is organized as a key-value mapping of 2^256 slots, each 256 bits wide. State variables declared at the contract level are stored in storage. Writing to storage is the most expensive operation in the EVM - storing a new value costs 20,000 gas (SSTORE to a fresh slot), while updating an existing slot costs 5,000 gas. Reading from storage (SLOAD) costs 2,100 gas for a cold read (first access in a transaction) and 100 gas for a warm read (subsequent accesses). Because of these costs, minimizing storage operations is the single most impactful gas optimization strategy.

Memory is a temporary, byte-addressable data area that exists only during the execution of a single function call. It is expanded dynamically as needed and is cleared after the function returns. Memory is significantly cheaper than storage - reading and writing cost only 3 gas per 32-byte word, plus a quadratic expansion cost for accessing higher memory offsets. Reference types (arrays, structs, strings) that are created inside functions and not stored persistently should use memory. Function parameters of reference types that are not modified should use calldata instead of memory for even lower gas costs.

Calldata is a read-only, non-modifiable data area that contains the function arguments passed in a transaction or external call. It is cheaper than memory because data in calldata does not need to be copied - it is read directly from the transaction input. External function parameters of reference types should always use calldata instead of memory when the data does not need to be modified within the function. This is a simple but frequently overlooked optimization that can save meaningful gas, especially when dealing with large arrays or strings.

The stack is where the EVM performs its computations. It holds local value-type variables (uint, bool, address, etc.) and intermediate computation results. The stack is limited to 1,024 elements, each 256 bits, and accessing it is very cheap (3 gas for PUSH/POP). However, Solidity developers rarely interact with the stack directly - the compiler manages it. One practical limitation is the "stack too deep" error, which occurs when a function has too many local variables (more than ~16). This can be resolved by grouping variables into structs or breaking the function into smaller sub-functions.

Code examples

Storage vs Memory vs Calldata

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

contract DataLocations {
    // State variables are always in STORAGE
    struct Player {
        string name;
        uint256 score;
        uint256 level;
        bool isActive;
    }

    Player[] public players;
    mapping(address => uint256) public playerIndex;

    // ============ Storage Reference ============

    /// @notice Modifies a player directly in storage
    function updateScoreStorage(uint256 _index, uint256 _newScore) external {
        // 'storage' keyword creates a REFERENCE to the storage slot
        // Changes to 'player' directly modify the blockchain state
        Player storage player = players[_index];
        player.score = _newScore; // This writes to storage (~5,000 gas)
        player.level = _newScore / 100;
    }

    // ============ Memory Copy ============

    /// @notice Reads a player into memory (does NOT modify storage)
    function getPlayerInfo(uint256 _index) external view returns (string memory, uint256) {
        // 'memory' creates a COPY of the storage data
        // Changes to 'player' would NOT affect storage
        Player memory player = players[_index];
        return (player.name, player.score);
    }

    /// @notice Demonstrates that memory is a copy, not a reference
    function memoryVsStorage(uint256 _index) external {
        // This creates a copy in memory - changes do NOT persist
        Player memory playerCopy = players[_index];
        playerCopy.score = 999; // Only modifies the memory copy!
        // players[_index].score is UNCHANGED

        // This creates a reference to storage - changes DO persist
        Player storage playerRef = players[_index];
        playerRef.score = 999; // Modifies actual storage!
        // players[_index].score is now 999
    }

    // ============ Calldata (cheapest for read-only external params) ============

    /// @notice Uses calldata for read-only array parameter (gas efficient)
    function addPlayers(Player[] calldata _newPlayers) external {
        for (uint i = 0; i < _newPlayers.length; i++) {
            // Reading from calldata is cheaper than memory
            players.push(_newPlayers[i]);
        }
    }

    /// @notice Calldata vs memory for string parameters
    function processNameCalldata(string calldata _name) external pure returns (uint256) {
        // Cheaper: reads directly from transaction input data
        return bytes(_name).length;
    }

    function processNameMemory(string memory _name) external pure returns (uint256) {
        // More expensive: copies the string from calldata to memory first
        return bytes(_name).length;
    }

    // ============ Gas Optimization Patterns ============

    /// @notice BAD: Multiple storage reads (expensive)
    function calculateBonusBad(uint256 _index) external view returns (uint256) {
        // Each access to players[_index] is a separate SLOAD (~2,100 gas cold)
        uint256 bonus = players[_index].score * 10;
        bonus += players[_index].level * 50;
        if (players[_index].isActive) {
            bonus *= 2;
        }
        return bonus;
    }

    /// @notice GOOD: Cache storage value in memory, read once
    function calculateBonusGood(uint256 _index) external view returns (uint256) {
        // Single SLOAD, then work with the memory copy
        Player memory player = players[_index];
        uint256 bonus = player.score * 10;
        bonus += player.level * 50;
        if (player.isActive) {
            bonus *= 2;
        }
        return bonus;
    }

    /// @notice Storage packing demonstration
    struct EfficientData {
        uint128 value1;    // Slot 0 (first half)
        uint128 value2;    // Slot 0 (second half) - packed!
        address owner;     // Slot 1 (20 bytes)
        uint96 timestamp;  // Slot 1 (12 bytes) - packed with address!
        uint256 bigValue;  // Slot 2 (full slot)
    }

    struct InefficientData {
        uint128 value1;    // Slot 0 (wastes second half)
        uint256 bigValue;  // Slot 1 (must start new slot)
        uint128 value2;    // Slot 2 (wastes second half)
        address owner;     // Slot 3 (wastes 12 bytes)
        uint96 timestamp;  // Slot 4 (wastes 20 bytes)
    }
    // EfficientData uses 3 slots; InefficientData uses 5 slots!
}

This contract demonstrates the critical differences between data locations. Storage references modify blockchain state directly, while memory creates temporary copies. Calldata is the cheapest option for read-only external parameters. The gas optimization examples show why caching storage reads in memory and proper struct packing are essential - the efficient struct layout uses 3 storage slots instead of 5, saving ~40% on storage costs.

Key points

Concepts covered

storage, memory, calldata, stack