BitrootBlog
Back to website ↗
© 2026 Bitroot · Content is for general information only and is not financial, investment, legal, or tax advice.
Editorial StandardsBack to website
← All articles
EVM foundations·2026/10/06·About 12 min

Storage Layout: How Solidity State Variables Land in Slots

State variables land in 32-byte slots in declaration order: small types share a slot, mappings and dynamic arrays derive positions by hashing, and inheritance order shifts slot numbers wholesale. These placement rules explain why upgradeable proxies must steer clear of the slots the compiler would otherwise assign.

An implementation contract that changed only its own owner field can overwrite a proxy contract's admin address with garbage. The root cause of such incidents is usually how Solidity places state variables. A contract has no runtime index from variable name to storage location; the compiler puts variables into 32-byte (32 bytes) slots in declaration order. delegatecall runs the implementation contract's code against the proxy's storage, so once the two sides disagree about slot assignment, a write lands on the other side's data.

A slot is both the unit of storage addressing and the smallest granularity the execution layer records for a single state access. Which slot a variable lands in, whether packing makes it share a slot, and how array and mapping elements derive their positions from a key decide two very practical questions: which proxy field a call will corrupt, and how large a transaction's state read/write set actually is.

Variables occupy 32-byte slots linearly, in declaration order

According to Solidity's official documentation, "Layout of State Variables in Storage and Transient Storage", apart from dynamic arrays and mappings, state variables are stored contiguously starting from the first declaration, with the first variable landing in slot 0. Each variable's byte count follows from its type, and consecutive variables that together take up fewer than 32 bytes are packed into the same slot. There are five rules: the first item in a slot is stored in the lower-order aligned position; a value type occupies only the bytes it actually needs; when it does not fit in the remaining space of the current slot it moves to the next slot; struct and array data always start at a new slot; and variables after a struct or array also start at a new slot.

Two easily overlooked exceptions affect slot-number arithmetic. A constant variable takes up no storage slot; its value is inlined where it is used. An immutable variable is encoded in the deployment bytecode and does not read storage at runtime. Take the example contract C from the official documentation: the constant c and the immutable d take no part in the layout, and counting them in the declaration ordinal would shift the slot numbers of every variable that follows. Transient storage is a separate layout of its own — the rules are the same, but the space is completely separate — so ordinary state variables and transient variables in the same contract can be interleaved freely without affecting each other.

Packing saves slots, not necessarily gas

Small types sharing a single slot is called packing. The two declarations below differ only in the order of the variables, and the slot count differs by one:

// takes up 3 slots
uint128 a; // slot 0, offset 0
uint256 b; // 32 bytes do not fit in the remaining space of slot 0, so it lands in slot 1
uint128 c; // slot 1 is full, so it lands in slot 2

// takes up 2 slots
uint128 a; // slot 0, offset 0
uint128 b; // slot 0, offset 16
uint256 c; // slot 1

The official documentation states plainly that using elements smaller than 32 bytes can increase gas consumption: the EVM operates in 32-byte units, so handling small types requires extra truncation or shifting. Packing's real payoff is “one slot access yields several values,” with reads and writes priced per slot. The flip side holds too: if a piece of logic writes to only one of those variables, the EVM must first read the entire slot, modify the relevant bytes, and write it all back, or it would clobber the other variables in the same slot. Cramming variables that are rarely accessed together into one slot turns a single write into a read plus a write.

That plants a concurrency-related observation: seen at slot granularity, packing binds several logically unrelated variables into one writable object.

Fixed-size arrays are inlined; dynamic arrays and mappings derive positions by hashing

The elements of a fixed-size array (such as uint256[3]) are inlined in slots in order, no different from variables declared one by one; when the total byte count exceeds 32 they naturally span multiple slots. Structs are the same, except that a struct and the variables after it must start at a new slot.

Dynamic arrays and mappings cannot be inlined, because their size is unpredictable. Their approach is to occupy a single slot p in the layout; the slot itself holds no data, and the data position is computed by keccak256.

A dynamic array's slot p stores the array length, and the elements start at keccak256(p) and are laid out contiguously under the fixed-size array rules; elements of 16 bytes or fewer can still share slots. Nested dynamic arrays apply the same rule recursively: for x of type uint24[][] declared at slot p, the element x[i][j] lives in slot keccak256(keccak256(p) + i) + floor(j / floor(256 / 24)).

A mapping's slot p stays empty but must be kept, and that reserved slot is exactly what keeps the data of two adjacent mappings from overlapping. The value for key k sits at keccak256(h(k) . p), where . denotes concatenation and h applies type-dependent processing to the key: value types are padded to 32 bytes the same way as in memory storage; keys of type string and bytes are not padded. The nested example in the documentation confirms this directly: for uint x; mapping(uint => mapping(uint => S)) data;, the slot of data[4][9].c is keccak256(uint256(9) . keccak256(uint256(4) . uint256(1))) + 1; the trailing +1 comes from the slot offset of struct member c within S, while the two uint16 members a and b have already been packed into the same slot.

The encoding of bytes and string needs a separate note, because they are not a simple wrapper around bytes1[]. When the data is 31 bytes or fewer, the data itself and the length sit in the same slot: the data is left-aligned in the high-order bytes, and the lowest byte stores length * 2. When the data reaches or exceeds 32 bytes, slot p stores length * 2 + 1, and the actual data sits in the region starting at keccak256(p). The two cases are told apart by the lowest bit: 0 for short data, 1 for long data.

Composite types are located by the same recursion. Following the rules above, when uint8[4] is the element type of a dynamic array, its four 1-byte values pack neatly into a single slot; for uint[3][], each element is a fixed-size array of 3 slots, so the elements are laid out at 3-slot intervals, and x[i] starts at keccak256(p) + 3 * i. These two examples are special-case derivations of the rules; the official documentation gives only the nested uint24[][] example and does not spell out these two verbatim.

All of this has a direct consequence: the slot positions of array elements and mapping values depend on runtime keys or indices, so they cannot be enumerated at compile time, and static analysis that only reads the source cannot derive the complete set either.

Layout is compiler output that can be exported and compared

Slot numbers need not be guessed at. Solidity's standard JSON interface can export a contract's storage layout, and the output has two keys, storage and types: each entry in the storage array gives astId, contract, label, offset, slot, and type, while types describes how each type is encoded — the value inplace means inline layout, mapping and dynamic_array mean derivation based on keccak256, and bytes means choosing between a single slot and the hashed region according to length. The slot value can be very large and is represented as a string in JSON. The documentation also notes that this output format is still considered experimental and may change in a non-breaking Solidity release, so it suits one-off cross-checks but not an interface to depend on long term.

Inheritance order and slot boundaries determine upgradeability

In contracts that use inheritance, the order of state variables is determined by the contract's C3-linearized order, starting from the most base contract in the inheritance chain. When packing allows it, variables from different contracts still share the same slot, and base-class and derived-class variables can coexist in one slot.

This rule explains two kinds of upgrade incidents. One is adding a variable to a base class: as long as the subclass has already declared variables of its own, the new base-class variable displaces an existing subclass variable's slot and reads old data as if it were the new variable. The other is inserting a variable before existing ones or changing a variable's type, with the same effect. OpenZeppelin's upgrade documentation states the constraint bluntly: new variables may only be appended at the end; if a variable is removed from the end, storage is not cleared, and a later variable added at the same position will read the leftover historical value.

For cases where you want to control the layout actively, Solidity lets a contract declare a custom layout start. The official documentation's example reads pragma solidity ^0.8.29; and contract C is A, B layout at 42, which shifts the slot numbers of every static variable in the inheritance tree wholesale; the documentation does not say which compiler version introduced the capability, and no version claim is made here either. The declaration applies only to that inheritance tree, so when A and B are deployed on their own their layouts still start at slot 0. Note that this only shifts the starting point: the data positions of dynamic arrays and mappings change along with the base slot.

Proxy upgrades: why storage collisions are inevitable, and how reserved slots avoid them

The mechanism of an upgradeable proxy is: the proxy holds the storage and the balance, and executes the implementation contract's code through delegatecall. Because delegatecall preserves the caller's storage context, slot 0 and slot 1 as the implementation contract sees them are the proxy's slot 0 and slot 1. If the proxy also declares an address public admin;, it occupies slot 0, while the implementation contract's first state variable also occupies slot 0, so either side's write overwrites the other's. That is a storage collision. The problem is not that one side wrote bad code: shared storage plus two sides compiled independently is a combination that produces collisions on its own.

EIP-1967's solution is to skip compiler-assigned slots and fix a set of agreed slots:

PurposeSlot positionDerivation
Implementation contract address0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbcbytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1)
Beacon contract address0xa3f0ad74e5423aebfd80d3ef4346578335a9a72aeaee59ff6cb3582b35133d50bytes32(uint256(keccak256('eip1967.proxy.beacon')) - 1)
Admin address0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103bytes32(uint256(keccak256('eip1967.proxy.admin')) - 1)

There are clear reasons behind the choice of these numeric parameters. The slot position is taken from the keccak256 of a string, and because that string does not begin with a storage index it cannot coincide with the slots the compiler assigns incrementally from 0; the value itself is very large, which moves it further away from the range of ordinary variables. Subtracting 1 at the end makes the hash preimage unknowable, preventing someone from constructing a mapping key that lands exactly on that slot through keccak256(h(k) . p). EIP-1967 also recommends that any function that writes these slots emit a corresponding event, because on-chain monitoring has a hard time tracking changes to arbitrary slots directly.

Beyond EIP-1967 there are two common approaches. The older one is the storage gap: declare a fixed-size array at the end of a base class, for example uint256[49] __gap;, to reserve slots for future variables, and shrink __gap in step when the base class gains a variable. OpenZeppelin's documentation notes that this adds no gas cost, but its failure modes are equally concrete: forgetting to shrink the gap, or adding a variable to a base class while the subclass already has variables of its own, brings collisions back. The newer approach is ERC-7201 namespaced storage layout, which puts a group of variables into a struct annotated with @custom:storage-location erc7201:<NAMESPACE_ID>; the position is computed as keccak256(keccak256(id) - 1) & ~0xff. The trailing & ~0xff aligns the namespace to 256 slots, and the reason the documentation gives is that this can serve as a future optimization: after the migration to Verkle state trees, gas rules may emerge in which 256 slots warm up together. Upgradeable versions of OpenZeppelin Contracts since 5.0 have adopted this convention.

One premise is worth adding at the end: Solidity's documentation treats storage layout as part of the language's external interface, because storage pointers can be passed to library functions, and any change to the layout rules counts as a breaking change. Proxy designs that hard-code addresses by slot are viable only because the layout rules are promised to stay stable over the long run.

The slot is the smallest unit of the read/write set

Putting the rules above together: for access pricing, the smallest state access the execution layer can observe is an address plus a slot. EIP-2929 is the most direct evidence: it maintains two sets per transaction, accessed_addresses and accessed_storage_keys, the latter with element type Set[Tuple[Address, Bytes32]], and prices at that granularity. A first access to a storage slot costs COLD_SLOAD_COST, or 2100 gas; a slot already accessed costs WARM_STORAGE_READ_COST, or 100 gas; a first access to an account address costs COLD_ACCOUNT_ACCESS_COST, or 2600 gas. Cold/warm pricing is per slot, which shows that the execution layer itself treats slots as the unit of access.

Three concurrency-related engineering inferences follow.

EIP-2929 defines the smallest unit of access pricing; the specification does not define what granularity conflict detection should use under parallel execution. The following is an inference from the pricing granularity: packing binds small variables into one slot, which means that when the read/write set is recorded at slot granularity, two transactions writing different variables in the same slot still conflict. Eliminating that kind of false conflict requires detection to be refined down to intra-slot offsets and byte differences, at the cost of higher overhead per comparison.

The slot positions of dynamic arrays and mappings depend on key values, so the read/write set is unknowable at compile time and can only be recorded during execution and checked after it. This is also why optimistic concurrency control (OCC) needs read sets and write sets and cannot simply adopt the database approach of developer pre-declaration (see “Optimistic Concurrency Control (OCC) Primer: From Databases to On-Chain Execution” and “Conflict Hotspots and Workloads: When Parallel EVM Actually Helps” on this site).

A slot-level read/write set is an upper bound, not an exact set. A transaction may change only one byte of a slot yet be recorded as writing the whole slot; it may also access a slot that never actually took effect because a later path reverted. Treating the read/write set directly as the conflict predicate yields an inflated conflict rate that has to be absorbed through rollback and re-execution.

The boundaries deserve the same clarity. If an implementation contract adopts ERC-7201 namespaced layout throughout, in principle it no longer collides with the proxy's slots, at the cost that arbitrary slot numbers can no longer be read off from source order, and tools that only recognize source order (some static analyzers, block explorers) stop working. In addition, sstore in inline assembly can write to any slot outside the compiler's layout, and no layout tool that only parses the AST can cover such writes — another recurring source of uncertainty when analyzing a contract's storage behavior.

Sources

  • Solidity documentation, Layout of State Variables in Storage and Transient Storage: https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
  • EIP-1967, Proxy Storage Slots: https://eips.ethereum.org/EIPS/eip-1967
  • ERC-7201, Namespaced Storage Layout: https://eips.ethereum.org/EIPS/eip-7201
  • EIP-2929, Gas cost increases for state access opcodes: https://eips.ethereum.org/EIPS/eip-2929
  • OpenZeppelin documentation, Writing Upgradeable Contracts: https://docs.openzeppelin.com/upgrades-plugins/writing-upgradeable

Further reading

Why a Single-Threaded EVM Caps TPS: Congestion History and the Execution ModelPreviousConflict Hotspots and Workloads: When Parallel EVM Actually HelpsRelatedOptimistic Concurrency Control (OCC) Primer: From Databases to On-Chain ExecutionRelatedBitroot Optimistic Parallelization: Detection, Re-execution, and DeterminismMechanics
← PreviousThe CALL Family Compared: CALL, CALLCODE, DELEGATECALL, and STATICCALL
Contents
Variables occupy 32-byte slots linearly, in declaration orderPacking saves slots, not necessarily gasFixed-size arrays are inlined; dynamic arrays and mappings derive positions by hashingLayout is compiler output that can be exported and comparedInheritance order and slot boundaries determine upgradeabilityProxy upgrades: why storage collisions are inevitable, and how reserved slots avoid themThe slot is the smallest unit of the read/write setSourcesFurther reading
Reading settings