← marcinbury

Storage Layout Discipline for Upgradeable Contracts

Aug 2026

In a proxy-upgradeable system, storage layout is the ABI between implementation versions - and the Solidity compiler enforces none of it.

The compiler has no idea your contract is upgradeable

A proxy delegatecalls into an implementation contract, so every SLOAD/SSTORE the implementation emits executes against the proxy's storage, targeting a raw slot number. Solidity assigns those slot numbers deterministically, in source order, at compile time - and it has no concept of "this contract is version 2 of that contract" or "this slot must keep meaning what it meant last week". Two contracts with unrelated names and unrelated business logic can be layout-compatible. Two contracts that are textually almost identical, differing only in the order two base contracts are listed, can be layout-incompatible. The compiler produces both with zero diagnostics either way.

Everything that keeps upgradeable contracts from silently corrupting their own state - the __gap array, ERC-7201 namespaced storage, "never reorder your is list" - is a convention layered on top of a language that doesn't know upgrades exist. This post works through how the slot-assignment algorithm behaves, where each convention comes from mechanically, and the specific ways each one still breaks in practice.

The mechanism: why slot assignment is deterministic, and why that cuts both ways

Solidity's storage layout algorithm is simple to state and unforgiving in practice. State variables are assigned to slots starting from slot 0, in declaration order, and for inheriting contracts, layout proceeds base-to-derived - the most-base contract's variables occupy the lowest slots, then the next contract up the hierarchy, and so on, with the most-derived contract's own variables landing last. A fixed-size array uint256[N] consumes exactly N consecutive slots - one slot per element, no more, no less.

This determinism is the entire reason the gap pattern works: if a base contract reserves uint256[50] __gap, every contract that inherits from it is guaranteed - by the same deterministic rule - to start its own variables 50 slots after the base contract's real fields, regardless of how many of those 50 slots are eventually consumed by future upgrades. The mechanics are worth stating precisely:

uint256[50] private __gap;: Allocates 50 consecutive slots. Slots default to 0 (never written). Child contracts inherit slot offsets. Child's first slot = parent's last slot + 1.

And the refactor rule that keeps a base contract upgrade layout-compatible with everything built on top of it:

Before: uint256 x; uint256[50] __gap;                    // x at 0, gap at 1..50
After:  uint256 x; uint256 newVar; uint256[49] __gap;    // x at 0, newVar at 1, gap at 2..50
// Both layouts have child's first slot at 51 (gap absorbs newVar).

The same determinism is exactly why the pattern is fragile. It only holds if every future edit to the base contract respects the arithmetic: one new variable in, gap shrinks by exactly one. There is no compiler-level linkage between the size of __gap and the presence of new fields above it - nothing stops you from adding newVar and leaving __gap at [50], and nothing warns you when you do. Refactoring a base contract like this must shrink the gap by exactly the number of new variables added - otherwise every child contract's layout shifts, silently.

PoC: the gap pattern done correctly, and done wrong

The following reproduces that refactor directly, extended with an assembly-based slot probe that reads the concrete slot number off a state variable, so the numbers below are directly verifiable rather than asserted.

Correct refactor - gap shrinks by exactly one

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

// ---- V1: base contract as originally shipped ----
contract V1 {
    uint256 public x;              // slot 0
    uint256[50] private __gap;     // slots 1..50
}

// ---- V2: adds newVar, gap shrinks 50 -> 49 ----
contract V2 {
    uint256 public x;              // slot 0
    uint256 public newVar;         // slot 1  (carved out of the gap)
    uint256[49] private __gap;     // slots 2..50
}

// ---- Downstream contracts, unmodified across the upgrade ----
contract ChildV1 is V1 {
    uint256 public childVar;
    function childVarSlot() external pure returns (uint256 slot) {
        assembly { slot := childVar.slot }
    }
}

contract ChildV2 is V2 {
    uint256 public childVar;
    function childVarSlot() external pure returns (uint256 slot) {
        assembly { slot := childVar.slot }
    }
}

ChildV1.childVarSlot() and ChildV2.childVarSlot() both return 51. x stays at slot 0; newVar is placed into what used to be gap space. The gap absorbs the loss and still ends at slot 50. The childVar lands at 51 either way - confirmed by compiling both versions and calling childVarSlot() on each.

Failure mode - gap left at its original size

// ---- V2Bad: newVar added, __gap NOT shrunk ----
contract V2Bad {
    uint256 public x;              // slot 0
    uint256 public newVar;         // slot 1
    uint256[50] private __gap;     // slots 2..51  (still 50 elements!)
}

contract ChildV2Bad is V2Bad {
    uint256 public childVar;
    function childVarSlot() external pure returns (uint256 slot) {
        assembly { slot := childVar.slot }
    }
}

Walking the numbers:

ContractxnewVar__gap rangechildVar slot
V1 / ChildV10-1..5051
V2 / ChildV2 (gap shrunk)012..5051
V2Bad / ChildV2Bad (gap not shrunk)012..5152

If a proxy currently runs ChildV1 and gets upgraded to ChildV2Bad, the deployed proxy storage already has real childVar data in slot 51 from before the upgrade. Post-upgrade, ChildV2Bad's code reads and writes childVar at slot 52 - previously untouched gap padding, reading as zero. The result isn't a revert. It's silent, structural data loss: the old childVar value becomes permanently orphaned in slot 51, unreachable by the new implementation, while the contract behaves as though childVar reset to zero. Every variable in every contract further down the inheritance chain shifts by the same offset, compounding the corruption per additional field.

Inheritance order is load-bearing, and a diff won't show you that

Because layout proceeds base-to-derived, the literal order of names in an is A, B list is not cosmetic - it's a second, independent axis of slot assignment, separate from what any individual contract declares. Solidity's C3 linearization makes this precise:

For contract C is A, B: Linearization (C3): C -> B -> A -> Object. Storage layout (most-base first): A's vars, then B's vars, then C's vars.

For contract C2 is B, A: Linearization: C2 -> A -> B -> Object. Storage layout: B's vars first, then A's vars.

Same set of variables, different slot assignment. Silent.

contract A { uint a; }
contract B { uint b; }
contract V1 is A, B { }   // slot 0 = a, slot 1 = b
contract V2 is B, A { }   // slot 0 = b, slot 1 = a (swapped)

The example that matters most for real upgrade PRs is swapping two base contracts that look interchangeable in a diff:

V1: contract V1 is ERC20Base, Ownable  -> slot0 = totalSupply, slot1 = owner
V2: contract V2 is Ownable, ERC20Base  -> slot0 = owner,       slot1 = totalSupply

Proxy pointing at V2's code reads V1's storage with V2's offsets - totalSupply and owner swap.
NO COMPILE WARNING. NO RUNTIME WARNING.

A reviewer scanning contract V2 is Ownable, ERC20Base against contract V1 is ERC20Base, Ownable sees only a reordering of names on one line - no changed types, no new fields, nothing a naive "did the state variables change" check would flag, because no state variable declaration changed at all. The diff is one line. The consequence is that owner reads as whatever totalSupply used to be.

Multi-base gap patterns behave the same way, with gaps summing in linearization order - as this two-parent example shows:

contract A { uint[10] __gap_a; }
contract B { uint[10] __gap_b; }
contract C is A, B { uint myVar; }    // myVar at slot 20
contract C2 is B, A { uint myVar; }   // myVar still at slot 20 but A/B positions swapped

The aggregate slot count for myVar is unaffected by which parent comes first (20 in both cases, since |A| + |B| is order-independent), but every field belonging to A or B individually moves - A's fields occupy 0..9 in C and 10..19 in C2, and vice versa for B. Any off-chain indexer or vm.load assumption keyed to a specific base contract's absolute slot breaks even though myVar happens to land in the same place. The same hazard shows up in a more general form: adding a new parent contract to an existing inheritance list inserts that parent's variables (and gap) at the most-base position, shifting every downstream slot - the same shape as the swap above, just triggered by an addition instead of a reorder.

ERC-7201: moving off linear slot arithmetic entirely

The gap-and-inheritance-order hazards above share one root cause: linear, declaration-order slot assignment starting from slot 0. ERC-7201 ("Namespaced Storage Layout") sidesteps it by not using linear slots at all - each logical storage region gets a slot computed as a keccak256 hash of a human-readable namespace string, landing at an effectively arbitrary, high-entropy location in the 2²⁵⁶ slot space, independent of declaration order or inheritance position.

Solidity has direct compiler support for this since version 0.8.35: a magic global erc7201("namespace.id") that computes the canonical formula and returns it as a uint256:

Built-in erc7201("namespace.id") ... returns the canonical ERC-7201 namespaced storage slot. Computes keccak256(keccak256(name) - 1) & ~0xff.

The builtin is flexible about how the namespace is supplied, with strict typing:

CallResult
erc7201("ns.foo")compile-time constant uint256
erc7201(stringVar)runtime: emits a Yul erc7201 builtin call
erc7201(bytes("foo"))rejected - Error 6896 (argument must be string, not bytes)
erc7201("a", "b")rejected - too many arguments
erc7201()rejected - too few arguments

The runtime-argument case is notable on its own: most Solidity magic globals only fold at compile time, but erc7201 also supports a non-literal string argument by emitting an actual Yul builtin call, computing the hash on-chain.

A correct ERC-7201 slot declaration looks like this - a struct holding the namespace's state, a constant slot computed via the builtin, and an internal accessor that lands a storage pointer on that slot:

/// @custom:storage-location erc7201:example.main
struct MainStorage {
    uint256 value;
    mapping(address => uint256) balances;
}

// slot = keccak256(keccak256("example.main") - 1) & ~0xff
uint256 constant MAIN_STORAGE_SLOT = erc7201("example.main");

function _getMainStorage() internal pure returns (MainStorage storage $) {
    assembly {
        $.slot := MAIN_STORAGE_SLOT
    }
}

Because the namespace string, not source position, determines the slot, adding fields to MainStorage, reordering base contracts, or introducing new parents no longer moves this struct's slot - the entire class of hazards above doesn't apply to data reached through _getMainStorage().

The arithmetic gotcha: erc7201 hashes overflow when combined

The erc7201 output is deliberately high-entropy - the top 248 bits behave like a uniformly random keccak digest, with only the bottom byte forced to zero by the & ~0xff mask. That entropy is exactly what makes the hash safe as a namespace anchor, and exactly what makes it dangerous to use as an operand in checked arithmetic:

Roughly 50% of all namespaces produce a hash > 2**255 (top bit set). The canonical solc-test namespace "main:example" happens to produce such a hash, so hash + hash overflows to (hash << 1) mod 2**256, which is less than hash - checked arithmetic panics 0x11.

The canonical solc fixture asserts this deterministically for the namespace "main:example":

contract C {
    function f() public pure returns (uint) {
        return erc7201("main:example") + erc7201("main:example");
        // Panics 0x11 - hash > 2**255, doubling overflows
    }
}

The patterns and their outcomes:

PatternOutcome
erc7201("X") + erc7201("X")Panic 0x11 ~50% of the time
2 * erc7201("X")Panic 0x11 ~50% of the time
erc7201("X") + erc7201("Y")Panic 0x11 ~50% of the time (sum is random)
erc7201("X") + 1Safe (only overflows for hash == 2**256-1, probability ~0)
erc7201("X") | offset (bitwise OR)Safe - low byte is 0, OR with a bounded offset just sets bits
unchecked { erc7201("X") + erc7201("X") }Wraps silently - safe only if wrap is semantically intended

The pattern that makes this an audit-relevant footgun rather than a curiosity is derived-slot arithmetic. A common namespaced-storage idiom computes a per-item slot as erc7201("parent") + uint256(keccak256(itemKey)) - adding a keccak-derived offset to the namespace root. Both operands are high-entropy 256-bit values, each with roughly 50% odds of having its top bit set, so the addition has a structurally non-zero panic probability per namespace/key combination. The fix is either unchecked { } - semantically valid here since slot space is the full 256-bit modular ring, so wraparound isn't a correctness bug - or a bitwise OR against a bit-width-bounded offset instead of addition. There's a refactor trap here too: changing the namespace string from one that hashes into the low half of the range to one that hashes into the high half silently flips previously compile-clean, test-passing arithmetic into a runtime panic, with no code-level indication of why.

Other layout edge cases worth knowing

Mappings and dynamic arrays are exempt from the layout-at storage-end check. Their data doesn't sit at a fixed offset from the declared base slot. Instead, it lands at a keccak-derived location that is effectively uniform over the entire uint256 range, no matter where the base slot itself is. For a mapping keyed by a value type such as uint, address, or bytes32, the value's slot is keccak256(abi.encode(key, slot)). For a dynamic array, the elements start at keccak256(abi.encode(slot)), with element i sitting at that hash plus i.

string and bytes keys are the one case where that formula doesn't apply, and it's an easy mistake to carry it over anyway. The compiler doesn't ABI-encode the key at all. It hashes the raw, unpadded key bytes concatenated directly with the slot, so the real formula is keccak256(key . slot). That's abi.encodePacked semantics, not abi.encode, and the two produce different hashes once the key is dynamically sized. If you recompute a mapping(string => V) or mapping(bytes => V) slot using the value-type formula above, you'll get the wrong answer.

The storage-end check itself only counts linear storage: plain value types, fixed-size arrays, and structs, whose locations are baseSlot + fixed_offset.

Storage shapeSubject to layout-at end-of-storage check?
uint x; (linear)Yes
uint[10] x; (fixed-size array)Yes
struct S { uint a; uint b; } S x;Yes
uint[] x; (dynamic array)No - data at keccak256(slot)
mapping(K=>V) m; (value-type key)No - data at keccak256(abi.encode(key, slot))
mapping(string/bytes=>V) m;No - data at keccak256(key . slot) (unpadded key, not abi.encode)

Operationally: an ERC-7201-style namespace containing only mappings and dynamic arrays can safely anchor at any base slot, however large. Mixing linear fields with mappings inside the same high-slot namespace requires accounting for the linear fields separately against the end-of-storage limit.

Storage-array clearing near the 2²⁵⁶ boundary was silently broken, fixed in 0.8.32 (tracked upstream as SOL-2025-1). Any storage array whose slots straddled the wraparound point (slot near 2²⁵⁶ - 1 wrapping back to 0) had its clearing loop - used by delete, .pop(), .push()'s zero-init, size-mismatched assignment, and legacy-backend array copy - terminate immediately without executing, because the naive bound check i < end failed once end had wrapped smaller than start. Severity is low since triggering it requires an intentionally engineered layout - a large nested fixed-array inside a mapping value, or an explicit layout at near the top of the address space - but it's a concrete audit pattern worth checking for any contract compiled below 0.8.32 that uses layout at near 2**256 or large mapping-of-fixed-array structures. It's relevant here because ERC-7201 namespacing and custom layout at bases both intentionally place storage away from slot 0 - the exact precondition this bug needed.

Audit checklist for an upgrade PR

Everything above converges on one conclusion: reviewing an upgrade PR by reading the source diff is not sufficient, because every hazard here is invisible in a diff that shows what changed syntactically, not what moved semantically in slot space.

  1. Diff the full computed storage layout, not the source. Compile both the pre- and post-upgrade implementation and compare solc --storage-layout output slot-by-slot, not contract-by-contract. A one-line is reordering or a field addition without a matching gap reduction produces zero source-level signal but a full slot-level shift.
  2. Verify gap arithmetic exactly. For every base contract with a __gap, confirm: (new fields added this upgrade) + (new __gap size) == (old __gap size). Any mismatch shifts every slot in every contract that inherits from that base, compounding downstream.
  3. Verify the inheritance list order is byte-for-byte unchanged, or, if it legitimately changed, recompute the full layout rather than assuming the change is cosmetic - including additions of new parent contracts, which insert at the most-base position and shift everything above them.
  4. For ERC-7201 namespaces, recompute the constant independently rather than trusting a hardcoded hex literal, and confirm it matches keccak256(keccak256(name) - 1) & ~0xff for the intended namespace string. Check for collisions across namespaces and for exact-match drift against any prior-version string - a single-character typo silently mints an unrelated slot with no compiler error.
  5. Audit any arithmetic performed on an erc7201() result. Flag checked +/* combining two hash-derived values, or a hash-derived value with another high-entropy operand; require either unchecked { } with a comment justifying modular-wraparound safety, or a bitwise-OR-with-bounded-offset instead.
  6. For contracts using layout at near the top of the address space, confirm solc >= 0.8.32 if linear (non-keccak-derived) data sits near the boundary, per SOL-2025-1.
  7. Treat this as a recurring regression check, not a one-time review. Re-run the layout diff on every subsequent upgrade PR - the gap-shrink and inheritance-order invariants must hold across the entire version history, and one silently broken link corrupts every version built on top of it.

Closing

None of the mechanisms described here are bugs in the compiler-team sense - slot assignment by declaration order, base-to-derived layout, and keccak-derived mapping/array slots are all working exactly as documented. The hazard is that Solidity treats storage layout as a pure function of source text with no notion of versioning, while proxy-upgradeable systems require layout to be a stable contract across versions of that source text. __gap, ERC-7201 namespacing, and "don't reorder your is list" are the community's answer to that mismatch - none of them are enforced by the type system, none of them produce a compiler diagnostic when violated, and all of them fail exactly the way an ordinary refactor looks: a one-line diff with no red flags, that happens to relocate a state variable.