DEV Community

metadevdigital
metadevdigital

Posted on

Solidity Storage vs Memory vs Calldata: A Developer's Guide

Solidity Storage vs Memory vs Calldata: A Developer's Guide

You're deploying next week. Your contract passed a light security review but you haven't optimized gas at all. Your state variables are scattered across storage slots and you're writing to storage inside loops. That's going to cost users real money on mainnet.

The difference between storage, memory, and calldata isn't theoretical—it's the difference between a contract that costs $80 per transaction and one that costs $8.

The Cost Breakdown

Storage writes are the most expensive operation you can do in Solidity. A cold storage write (slot never written before) costs 22,100 gas. A warm write (slot already exists) costs 2,900 gas. At current mainnet prices (~50 gwei), that's roughly $1.10 per cold write and $0.15 per warm write. Memory is cheaper—writing 32 bytes costs 3 gas. But memory doesn't persist; it exists only during execution. Calldata is free (paid by the caller in transaction data). Reading from calldata costs 4 gas per byte (or 16 gas per byte for zero bytes in the transaction).

Most developers write like they're working with unlimited gas budgets.

Uniswap V3's swap function uses calldata extensively for routing information. When you call it with a complex path, you're not storing that path—you're just reading it from calldata. That swap might cost 120,000 gas instead of 200,000+ if they'd stored everything in memory or storage. (Yeah, the gas savings compound across millions of calls. This isn't premature optimization.)

Storage: The Persistent Layer

Storage is your contract's long-term memory. Every variable declared at contract level lives here. Each storage slot is 32 bytes and costs serious gas to modify.

// BAD: Writing to storage in a loop
contract BadExample {
    uint256 public totalValue;

    function addValues(uint256[] calldata values) external {
        for (uint i = 0; i < values.length; i++) {
            totalValue += values[i];  // ~22,100 gas first time, ~2,900 after
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

That loop writes to totalValue every iteration. On an array of 10 elements, you're spending 22,100 + (9 × 2,900) = 48,200 gas just on storage writes.

// GOOD: Accumulate in memory, write once
contract GoodExample {
    uint256 public totalValue;

    function addValues(uint256[] calldata values) external {
        uint256 sum = 0;  // Memory variable
        for (uint i = 0; i < values.length; i++) {
            sum += values[i];  // ~3 gas per iteration
        }
        totalValue = sum;  // Single storage write: ~22,100 gas
    }
}
Enter fullscreen mode Exit fullscreen mode

Same logic. 10 iterations. First version: 48,200 gas. Second version: ~22,130 gas. You just saved users $2-3 per call.

Storage layout matters. Solidity packs variables together when they fit. A uint128 and a uint128 share one slot. Reading one costs the same as reading two (cold read = 2,100 gas for the slot, regardless).

// Efficient packing
contract Packed {
    uint128 lastPrice;      // Slot 0 (first 16 bytes)
    uint128 lastTimestamp;  // Slot 0 (second 16 bytes)
    address token;          // Slot 1 (20 bytes, pads to 32)
    uint256 reserves;       // Slot 2
}
Enter fullscreen mode Exit fullscreen mode

versus

// Inefficient packing
contract Unpacked {
    uint256 lastPrice;      // Slot 0
    uint256 lastTimestamp;  // Slot 1
    address token;          // Slot 2
    uint256 reserves;       // Slot 3
}
Enter fullscreen mode Exit fullscreen mode

Unpacked version requires 4 cold reads to initialize. Packed version needs 3. On protocols that read these values repeatedly, that's hundreds of thousands of gas saved across users' lifetimes.

Memory: The Scratch Pad

Memory is temporary storage that exists only during function execution. It's cheap to write (3 gas per 32 bytes) but it's gone after the function returns. Use it for intermediate calculations, temporary arrays, and struct building.

// Memory for batch operations
function batchTransfer(
    address[] calldata recipients,
    uint256[] calldata amounts
) external {
    uint256 totalAmount = 0;

    for (uint i = 0; i < recipients.length; i++) {
        totalAmount += amounts[i];
    }

    token.transferFrom(msg.sender, address(this), totalAmount);
}
Enter fullscreen mode Exit fullscreen mode

Memory grows as you use it. The first 2KB is essentially free. After that, you pay 3 gas per 32 bytes per iteration of the memory quadratic formula—but you're never hitting that unless you're doing something unusual. Creating large memory arrays is expensive:

function getData() public view returns (uint256[] memory) {
    uint256[] memory result = new uint256[](100);
    // populate it
    return result;
}
Enter fullscreen mode Exit fullscreen mode

Don't do this in a loop. Creating 1000 arrays in sequence is expensive.

Calldata: The Cheap Import

Calldata is function argument data. It's immutable and persists on-chain (in transaction logs). Reading from it costs 4 gas per byte. For arrays and complex types, use calldata instead of memory when you just need to read:

// BAD: Copying calldata to memory unnecessarily
function process(uint256[] memory data) external {
    // data was copied from calldata to memory. Cost: 32 gas × length
    for (uint i = 0; i < data.length; i++) {
        // ...
    }
}

// GOOD: Read directly from calldata
function process(uint256[] calldata data) external {
    // No copy. Reads cost 4 gas per value
    for (uint i = 0; i < data.length; i++) {
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

This is why protocols that accept packed data use calldata. You're not parsing, just reading raw bytes.

Optimization Checklist

Are you writing to storage in loops? Move the write outside. Are function parameters arrays? Make them calldata, not memory. Are multiple state variables under 32 bytes each? Pack them into one slot. Do you have string storage for configuration? Consider bytes32 + events instead. Are you creating memory arrays unnecessarily? Use calldata or stack variables. How many cold storage reads is your contract doing? Can you batch them? Are you reading the same storage slot twice? Cache it in memory (costs 3 gas, saves 100+).

What Actually Happens

I found two bugs in my own protocol before launch doing exactly this kind of audit. One was a storage write inside a swap loop that should've been memory. The other was accepting token arrays as memory instead of calldata. First bug would've cost users an extra $0.50 per swap at scale. Second would've cost $0.30. On a protocol doing 10,000 swaps a day, that's $1,500-2,000 a day in unnecessary gas.

Test your contract on a testnet fork with realistic transaction volumes. Gas profiling isn't something you do after launch. The code works either way. The gas costs don't.

Top comments (0)