Upgradeable proxy patterns - Transparent, UUPS, Beacon - all rest on the same core contract: a proxy that holds the state and an implementation that holds the logic. The proxy delegates every call to the current implementation via delegatecall, which means the implementation's code executes against the proxy's storage, not its own.

This works cleanly as long as every version of the implementation agrees on what each storage slot means. Slot 0 is always owner. Slot 1 is always totalSupply. The proxy doesn't know or care - it just holds the bytes. Interpretation is entirely the implementation's responsibility.

Break that agreement and you have a storage collision.


How Solidity assigns storage slots

Solidity assigns state variables to slots sequentially, starting at slot 0, following two rules: declaration order within a contract, and inheritance linearization order across the hierarchy. The linearization algorithm Solidity uses is C3 linearization - the same one Python uses for method resolution order.

For a simple single-inheritance chain, slots are allocated from the most base contract down to the most derived. Base variables come first. Derived variables follow. The order is deterministic and predictable - right up until you change the inheritance chain in an upgrade.

The EVM has no concept of variable names. It only sees slot numbers. If your new implementation assigns a different meaning to slot 0 than the old one did, the EVM will silently comply.


The classic collision: inserting a parent with state

Here is ImplV1 - a minimal upgradeable contract. Two state variables, two functions. The storage layout is exactly what you expect.

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

contract ImplV1 {
    // slot 0
    address public owner;
    // slot 1
    uint256 public totalSupply;

    function init(address _owner) public {
        owner = _owner;
    }

    function getOwner() public view returns (address) {
        return owner;
    }
}

V1 implementation - owner at slot 0, totalSupply at slot 1.

Now an upgrade is needed. A developer adds a new parent contract that introduces a feature flag. The change looks harmless - a single boolean, a single function. The new contract is SneakyParent, and ImplV2 inherits from both.

// new parent injecting a state variable BEFORE owner in the linearization
contract SneakyParent {
    // slot 0 <-- unexpected!
    bool public someFlag;
}

contract ImplV2 is SneakyParent, ImplV1 {
    function flipFlag() public {
        someFlag = !someFlag;
    }
    // getOwner() now reads slot 1 (where totalSupply used to be), not the original owner
}

V2 inherits SneakyParent first - its variables are placed before ImplV1's in the layout.

Because ImplV2 is SneakyParent, ImplV1, C3 linearization places SneakyParent's variables before ImplV1's. The new layout is:

Slot V1 expected V2 actual
0 owner (address) someFlag (bool) ✗
1 totalSupply (uint256) owner (address) ✗
2 totalSupply (uint256) ✗

The proxy's storage hasn't changed. Slot 0 still holds the owner address written during V1. But when V2 code runs getOwner(), it reads owner - which V2's compiler placed at slot 1. Slot 1 in the proxy holds totalSupply. So getOwner() returns a garbage address derived from the supply value.

Worse: flipFlag() writes to slot 0 - where the actual owner address lives. Calling it corrupts the ownership record entirely. No malicious code. No exploit payload. Just an innocent-looking inheritance change.


Real consequences

Proxy admin slot overwrite

If a shifted variable lands on the EIP-1967 implementation slot (0x360894...b63) or admin slot (0xb53127...01), an upgrade or ownership transfer can silently point the proxy at an attacker-controlled address or permanently lock admin access. EIP-1967 specifically chose pseudo-random slot positions to reduce the chance of this happening - but it doesn't protect you from your own storage layout shifting into those positions.

Bypassed access guards

Boolean flags used for access control - initialisation guards, pause flags, re-entrancy locks - sit in fixed slots. Shift them and they read whatever was in the slot before. A pause flag that now reads a non-zero value from an old token balance will appear permanently enabled. An initialisation guard that reads zero from an empty slot will allow re-initialisation.

Corrupted mapping roots

Mappings in Solidity store their values at keccak256(key . slot). If the mapping's declared slot shifts, every derived storage key changes with it. All previous balance entries, approval records, and index positions become permanently unreachable - not deleted, just inaccessible. The data is still on-chain; the new implementation simply can't find it.

None of these failures produce a revert. The EVM executes silently. The contract behaves incorrectly - and continues to do so - until someone notices the wrong output.


The fix: strict storage discipline

Freeze your layout, append only

The cardinal rule: never insert a variable anywhere except the very end of the layout. No new variables at the top. No new base contracts with state. No reordering. Every upgrade may only append to what already exists. Removing a variable is equally dangerous - the slot doesn't disappear, it just becomes unnamed, which is worse.

EIP-1967 storage slots for proxy internals

Proxy-internal variables like _implementation and _admin must never be placed at sequential slots where they could collide with implementation variables. EIP-1967 defines specific pseudo-random positions computed as bytes32(uint256(keccak256("eip1967.proxy.implementation")) - 1). These slots are unlikely to be hit by sequential allocation and are the standard that Transparent and UUPS proxies follow.

Storage gaps in base contracts

Any base contract in an upgradeable hierarchy should reserve space for future variables using a storage gap. OpenZeppelin's convention is a private array at the end of each base contract:

contract BaseV1 {
    address public owner;         // slot 0
    uint256 public totalSupply;    // slot 1

    // reserves slots 2–51 for future variables in this contract
    uint256[50] private __gap;
}

When you need to add a variable to BaseV1 in a future upgrade, you add it before __gap and reduce the gap size by one. The derived contracts' slots are unaffected because the total footprint of BaseV1 never changes.

Use UUPS or Transparent proxies from OpenZeppelin

OpenZeppelin's proxy implementations enforce EIP-1967 slot usage and are designed with storage safety as a first-class concern. Rolling your own proxy from scratch introduces all the risks above without the battle-tested guard rails. If you need custom upgrade logic, extend the existing implementations rather than replacing them.

Automated detection with Slither

Slither ships a dedicated upgrade checker: slither-check-upgradeability. It compares two implementation versions and reports storage layout differences, variable reorderings, and missing initialiser guards. Run it on every upgrade before deployment - it catches exactly the class of error described in this article.

slither-check-upgradeability . ImplV1 --new-contract-name ImplV2

The rule

Upgrades may change behavior. They must never reinterpret history. The proxy's storage is a fixed record. Every implementation version is just a different lens through which that record is read. Point the lens somewhere else and you are not reading a different truth - you are misreading the same one.

Freeze the layout. Append only. Gap your bases. Verify with tooling before every upgrade. There is no shortcut here.