Solidity Storage

Solidity Storage

Introduction

Think of Ethereum contract storage like a giant library with infinite shelves, where each shelf (slot) is exactly 32 bytes wide. Many developers new to Solidity imagine storage working like a continuous bookshelf where books (data) are placed one after another. However, Ethereum's storage system is more like a magical library where books are placed according to specific rules that optimize space and access. Let's explore how this system works…

One of the most common misconceptions for newcomers to Solidity is to treat contract storage as if it were a traditional linear array of bytes (like in C or C++). In reality, Ethereum contract storage is more like a massive key-value store, conceptually a “map” from a 256-bit slot index to a 256-bit word. Internally, the blockchain stores this in a Merkle Patricia Tree (MPT), but for everyday Solidity usage, you can think of it as a mapping of slotIndex -> 32-byte data.

Table of contents:


Simple values

Consider a contract with two consecutive uint256 fields:

contract Test {
    uint256 public first;
    uint256 public second;
}

We might be tempted to think of first at byte offset 0x00 and second at byte offset 0x20 (i.e., 32 bytes later):

0x00: first
0x20: second (0x20 is hexa for 32 bytes)

However, Solidity’s storage model assigns them to consecutive slots (not consecutive bytes) numbered 0 and 1, each slot being 32 bytes:

Root (Merkle Patricia Tree)
   ├── Slot 0x00<value of first>
   └── Slot 0x01<value of second>

Packing Smaller Types

If you change both fields to smaller types, say uint16:

contract Test {
    uint16 public first;
    uint16 public second;
}

Because these two uint16 values fit into a single 32-byte slot, the Solidity compiler packs them. Both first and second are stored in the same 32-byte slot:

Root (Merkle Patricia Tree)
   └── Slot 0x00 → <16 bits for first> || <16 bits for second>

Mappings

Mappings also have a “base slot,” but each entry is stored at a slot calculated via a hash of the key concatenated with that base slot. For example:

contract Test {
    mapping(address => bool) public whitelisted;
}

The compiler assigns the base slot to whitelisted (say slot 0), but each specific address key is stored at:

keccak256(abi.encode(key, base_slot))

So, if we added whitelisted[0xb0B] = true, the storage becomes:

Root (Merkle Patricia Tree)
   ├── Slot 0x00 → <base slot for whitelisted>
   └── Slot keccak256(0xb0B || 0x00) → true

Structs

Structures are also allocated by slots, starting from the struct’s “base slot,” with their fields laid out in place sequentially (packing them when possible). For instance:

contract Test {
    struct Data {
        uint256 first;
        uint256 second;
    }
    Data public myData;
}

If myData is placed at slot 0, myData.first is in slot 0 and myData.second is in slot 1:

Root (Merkle Patricia Tree)
   ├── Slot 0x00 → <value of myData.first>
   └── Slot 0x01 → <value of myData.second>

Structs inside Mappings

If a mapping stores structs, each struct base is given by the mapping key hash, and then each field of the structs are consecutive in slots. Consider the following contract:

contract Test {
    struct Data {
        uint256 first;
        uint256 second;
    }
    mapping(address => Data) public info;
}

The mapping info itself might be assigned to slot 0.

Then, for a particular key addr, the struct’s base slot will be:

Open image-20241223-182051.png

The first field (info[addr].first) is at that hashed slot, and the second field (info[addr].second) is at the next consecutive slot (hashed slot + 1).

Hence:

Root (Merkle Patricia Tree)
   └── Slot 0x00 → <base slot for mapping info>
   └── Slot keccak256(addr || 0x00) → <value of info[addr].first>
   └── Slot (keccak256(addr || 0x00) + 1) → <value of info[addr].second>

Contract Upgrades and Storage

When you upgrade a contract (commonly via the proxy pattern or delegatecall-based approaches), the existing storage layout must remain consistent. The new implementation code will continue to read and write from the same slots in the proxy’s storage. Therefore:

  1. Changing the Order of State Variables

    If you reorder or remove variables in your new implementation, you risk corrupting stored data (e.g., a new uint256 might accidentally overwrite what used to be a mapping). That also applies to struct fields.

  2. Adding New Variables

    Typically, you should append new state variables at the end of your existing variable list. This ensures that the slot usage for old variables does not change.

  3. “Storage Gaps”

    Some upgradable contract designs include “storage gap” variables—arrays of unused fixed-size slots—to reserve space for future expansions. Doing so helps avoid collisions in storage if you add new data in subsequent upgrades.

  4. Always Keep the Same Compiler and Language Settings, If Possible

    Slight variations in compiler versions or optimization settings may alter how the layout is determined.


🔑 Key Takeaway: Solidity automatically optimizes storage by packing multiple small variables into a single slot when possible.

🚫 Common Pitfall: Be careful with ordering! Putting a uint256 between two uint8s will prevent packing:

In summary, Solidity’s storage is not a simple array of bytes. Instead, it’s a conceptual key-value store where each 256-bit “slot” is numbered. The compiler decides how to pack values (especially when you use smaller types like uint16) to optimize usage of these 256-bit slots. Mappings get assigned a base slot, and each key-value pair inside them resides at a hashed position (keccak256(key, base_slot)). Structs occupy consecutive slots starting from the struct’s own base slot.

When upgrading contracts, you must preserve the layout of existing storage—or carefully introduce new slots while respecting the old layout—to maintain consistent data across upgrades.