Skip to main content

Type a word from a question or an article title.

3 · Under the hood

Storage layout and variable packing in Solidity

How a contract stores data in 32-byte slots, how variables are packed, where mappings and arrays live and how the Coin-Trust deal struct is laid out.

4 min readUpdated:
In this article

A contract's persistent memory - storage - is the most expensive part of the EVM. A write costs thousands of units of gas, so developers think carefully about how to lay data out. This article explains the layout rules and then walks through a real example - the deal struct in the escrow contract. The basics of the EVM are in the article EVM under the hood.

32-byte slots

A contract's storage is a huge array of cells, each 32 bytes (256 bits), numbered from 0. These are the slots. By default a slot is empty (zeros), and it can be read at any time. A contract accesses a slot by number (SLOAD/SSTORE).

The compiler lays state variables out into slots in order of declaration.

solidity
contract Example {
    uint256 a;   // slot 0
    uint256 b;   // slot 1
    address c;   // slot 2 (takes 20 bytes of 32)
}

Packing

If consecutive variables are shorter than 32 bytes and fit together, the compiler packs them into one slot, right to left.

solidity
contract Packed {
    uint128 a;   // slot 0, bytes 0-15
    uint128 b;   // slot 0, bytes 16-31
    uint256 c;   // slot 1
}

contract NotPacked {
    uint128 a;   // slot 0
    uint256 c;   // slot 1
    uint128 b;   // slot 2 - no packing because of the order
}

The declaration order matters. Packed uses 2 slots, NotPacked uses 3.

Type sizes: bool and uint8 - 1 byte, uint16 - 2, uint64 - 8, uint96 - 12, address - 20, uint256 and bytes32 - 32.

Structs and arrays

  • A struct always starts a new slot, the same packing rules apply inside it; the variable after a struct also starts a new slot.
  • A static array of fixed length takes consecutive slots (packed for small types).

Mappings and dynamic arrays

These types do not take "space in a row" but use a hash to compute the location.

Mapping. The value for key k in a variable occupying slot p lives in the slot:

text
keccak256(k ‖ p)

Thanks to the hash function slots of different keys are scattered, yet computed without searching. A mapping has no "length" and you cannot get a list of keys: to enumerate records use events or a separate counter.

Dynamic array. The length lives in slot p, and the elements - starting at slot keccak256(p), in a row.

What it costs

OperationGas (order of magnitude)
Reading a slot (cold)~2,100
Reading a slot (warm)~100
Writing to an empty slot (0 → non-zero)~20,000 (+ 2,100 for "cold" access)
Changing a non-empty slot~2,900 (+ 2,100 for "cold" access)
Zeroing a slotpart of the gas refunded

One new slot at first write is on the order of 22,000 gas. If packing avoids occupying extra slots, the saving is substantial (optimization techniques).

Example: the deal struct in Coin-Trust

In the escrow contract each deal is a Deal struct located in mapping(uint256 => Deal). The fields are chosen to fit in 5 slots:

SlotFieldsBytes
0buyer (address), amount (uint96)20 + 12 = 32
1seller (address), two rates (uint16 × 2), deadline (uint64)20 + 2 + 2 + 8 = 32
2arbiter (address), disputeOpenedAt (uint64), status, outcome, durationPreset20 + 8 + 1 + 1 + 1 = 31
3token (address), offerExpiresAt (uint64), three bool flags20 + 8 + 3 = 31
4termsHash (bytes32)32

The struct has 17 fields in all. Without packing that would be 17 slots; packing cuts them to 5. The difference at first write is 12 slots × ~20,000 = around 240,000 gas per deal. This noticeably reduces the cost of creating an offer (fees).

That is why amount is a uint96 rather than a uint256: the maximum is about 7.9 × 10^28 units, enough even for tokens with 18 decimals. But then an explicit check is needed on write, otherwise the value would silently truncate (why this matters).

Why fitting into a slot is not always profitable

Packing saves space, but reading and writing part of a slot requires extra operations: masking and shifting. If one transaction changes and reads several fields of the same slot, the gain is large. If variables change in different transactions and independently, and merely "other" bits must be preserved, the win is less obvious. In practice: pack fields that change together, and measure gas (Foundry).

Visibility of storage

"Private" variables (private) are hidden only from other contracts. Storage data is public: anyone can read any slot through a node (eth_getStorageAt). Never store secrets in private variables (catalogue of vulnerabilities).

Let us trace how functions are protected from unauthorised calls: access-control patterns. Or how to save gas: gas optimization.

Mentioned in

Ready to try it?

Create your first deal in a couple of minutes. The money is locked in a smart contract and paid out by rules you can see in advance.