Why storage layout testing matters

In a proxy-based architecture, the proxy keeps the state while implementations provide logic. That means the storage layout of the new implementation must remain compatible with the old one. If a variable moves to a different slot, the proxy will still read the old slot, and your application may start interpreting unrelated data as balances, owners, flags, or mappings.

Typical failure modes include:

  • Reordering state variables
  • Inserting new variables in the middle of existing ones
  • Changing inheritance order
  • Modifying packed variables in a way that changes slot usage
  • Replacing a type with another type of different size
  • Removing variables without reserving space for future use

A storage layout diff test does not replace upgrade audits, but it gives you fast feedback during development and prevents obvious mistakes from reaching deployment.


What a storage layout diff test checks

A good test compares the compiled storage layout of two contract versions and flags changes that are unsafe for upgrades. At minimum, it should verify:

  • Variable order is unchanged for existing fields
  • Slot and offset for existing fields remain stable
  • Types remain compatible
  • Inheritance changes do not shift storage unexpectedly
  • Reserved gaps are still intact when used

The exact layout information is available from the Solidity compiler output. Tools such as Hardhat and Foundry can expose this metadata, and you can assert against it in tests or scripts.

ChangeSafe for upgrade?Why
Append a new variable at the endYesExisting slots remain unchanged
Reorder existing variablesNoSlot assignments shift
Change uint128 to uint256 in a packed slotUsually noPacking and offsets change
Add a new base contract before an existing oneNoInheritance order affects layout
Use a reserved storage gapYes, if consumed correctlyPreserves future extension space

A practical example: comparing two versions

Suppose you have version 1 of a vault contract:

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

contract VaultV1 {
    address public owner;
    uint256 public totalDeposits;
    mapping(address => uint256) public balances;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }
}

A later version adds a fee field. If you append it at the end, the layout is safe:

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

contract VaultV2 {
    address public owner;
    uint256 public totalDeposits;
    mapping(address => uint256) public balances;
    uint256 public feeBps;

    function deposit() external payable {
        balances[msg.sender] += msg.value;
        totalDeposits += msg.value;
    }
}

But if you insert feeBps before totalDeposits, the layout changes and the proxy would interpret the old totalDeposits slot as feeBps. That is exactly the kind of issue a diff test should catch.


Extracting storage layout from the compiler

The Solidity compiler can emit storage layout metadata. In many workflows, the easiest path is to compile both versions and inspect the storageLayout section.

For example, with Hardhat you can access the artifact output and compare the layout objects in a test. The important fields are typically:

  • slot
  • offset
  • label
  • type

A layout entry might look conceptually like this:

{
  "label": "totalDeposits",
  "slot": "1",
  "offset": 0,
  "type": "t_uint256"
}

For packed variables, offset becomes important because two fields may share the same slot. A change in packing can be just as dangerous as a slot shift.


Writing a layout diff test in Hardhat

A simple strategy is to compile both versions, load their storage layouts, and compare them field by field. The test should fail if any existing variable changes slot, offset, or type.

import { expect } from "chai";
import hre from "hardhat";

function indexByLabel(layout) {
  const map = new Map();
  for (const entry of layout.storage) {
    map.set(entry.label, entry);
  }
  return map;
}

describe("Storage layout compatibility", function () {
  it("keeps VaultV1 fields stable in VaultV2", async function () {
    const v1 = await hre.artifacts.readArtifact("VaultV1");
    const v2 = await hre.artifacts.readArtifact("VaultV2");

    const layout1 = v1.storageLayout;
    const layout2 = v2.storageLayout;

    const oldFields = indexByLabel(layout1);
    const newFields = indexByLabel(layout2);

    for (const [label, oldField] of oldFields.entries()) {
      const newField = newFields.get(label);
      expect(newField, `missing field ${label}`).to.not.equal(undefined);
      expect(newField.slot, `${label} slot changed`).to.equal(oldField.slot);
      expect(newField.offset, `${label} offset changed`).to.equal(oldField.offset);
      expect(newField.type, `${label} type changed`).to.equal(oldField.type);
    }
  });
});

This test is intentionally strict. It assumes that every field in the old version must remain exactly where it was. That is the right default for upgrade safety.

If your project uses a proxy pattern with reserved gaps, you can extend the test to allow new variables only after the last stable slot or inside a designated gap.


Handling inheritance safely

Inheritance is one of the most common sources of accidental layout drift. Solidity lays out state variables from base contracts in linearized order, so changing the inheritance tree can shift slots even if the child contract code looks unchanged.

Consider this pattern:

contract BaseConfig {
    address public admin;
}

contract Vault is BaseConfig {
    uint256 public totalDeposits;
}

If you later insert another base contract before BaseConfig, the layout can shift:

contract NewBase {
    uint256 public version;
}

contract Vault is NewBase, BaseConfig {
    uint256 public totalDeposits;
}

A layout diff test should compare the full inherited storage list, not just the variables declared directly in the child contract. That is why reading compiler metadata is better than trying to reason about layout manually.

Best practice

  • Treat inheritance order as part of the storage contract
  • Avoid adding new base contracts before existing ones in upgradeable systems
  • Prefer appending new state in the most derived contract when possible
  • Re-run layout checks whenever a base contract changes

Packed variables deserve special attention

Solidity packs smaller value types into a single 32-byte slot when possible. This saves gas, but it also makes layout changes more subtle.

For example:

contract PackedV1 {
    uint128 public a;
    uint128 public b;
}

Here, a and b may share a slot. If you later change b to uint256, the packing changes and the layout no longer matches. Even if the variable names stay the same, the storage interpretation changes.

A good diff test should compare both slot and offset, not just the variable name. That catches packing regressions that a naive slot-only check would miss.

Practical rule

If a variable participates in packing, assume it is highly sensitive to type changes. In upgradeable contracts, the safest approach is to avoid modifying packed fields at all once deployed.


Enforcing reserved storage gaps

Reserved gaps are a common pattern in upgradeable contracts. They intentionally allocate unused slots for future variables, reducing the chance that later additions will collide with existing state.

contract VaultUpgradeable {
    address public owner;
    uint256 public totalDeposits;
    uint256[48] private __gap;
}

A storage layout test can verify that:

  1. The gap still exists
  2. The gap size has not been reduced unexpectedly
  3. New variables consume only the reserved area

This is especially useful when multiple teams contribute to the same codebase. A developer may add a new field without realizing that the contract is already near a layout boundary. A test can stop that change immediately.

Recommended policy

PolicyBenefit
Reserve a gap in every upgradeable base contractLeaves room for future fields
Document what the gap is forPrevents accidental misuse
Track gap consumption in testsMakes upgrades auditable
Never repurpose a gap slot for unrelated stateAvoids hidden coupling

Detecting unsafe changes in CI

Storage layout diff checks are most valuable when they run automatically. Add them to your CI pipeline so every pull request validates compatibility before merge.

A typical workflow looks like this:

  1. Compile the current branch
  2. Load the baseline layout from the last released version or a pinned artifact
  3. Compare the layouts
  4. Fail the build on any incompatible change

If your release process is versioned, store a canonical layout snapshot in the repository. That snapshot becomes the contract for future upgrades. When a change is intentional, update the snapshot in the same pull request and review the diff carefully.

What to fail on

  • Missing existing variable
  • Changed slot or offset
  • Changed type
  • Changed inheritance order affecting layout
  • Reduced reserved gap without explicit approval

What to allow

  • New variables appended after all existing fields
  • New logic functions with no storage impact
  • Internal refactors that preserve layout exactly

A lightweight snapshot approach

For smaller teams, a snapshot file can be enough. Store the expected layout as JSON and compare it during tests.

Example snapshot structure:

{
  "owner": { "slot": "0", "offset": 0, "type": "t_address" },
  "totalDeposits": { "slot": "1", "offset": 0, "type": "t_uint256" },
  "balances": { "slot": "2", "offset": 0, "type": "t_mapping(t_address,t_uint256)" }
}

This approach is easy to review in pull requests. The downside is that it requires discipline: the snapshot must be regenerated only when the upgrade is intentionally approved.


Common mistakes to avoid

Comparing only variable names

A variable can keep the same name while moving to a different slot. Name-only checks are not enough.

Ignoring packed offsets

Two fields can share a slot. If you ignore offset, you may miss a breaking change.

Forgetting inherited storage

Base contracts contribute to the final layout. Always compare the full compiled layout.

Allowing silent type drift

Changing uint256 to uint128 or bytes32 to bytes16 can break compatibility even if the slot number stays the same.

Relying on manual review alone

Human review is important, but layout metadata gives you a deterministic safety net that catches mistakes consistently.


A practical upgrade checklist

Before merging an upgradeable contract change, verify the following:

  1. Existing storage variables keep the same slot and offset
  2. No inherited contract changes shift layout unexpectedly
  3. New variables are appended only after existing state
  4. Packed fields are unchanged unless the upgrade is explicitly designed around them
  5. Reserved gaps remain available for future use
  6. The test suite compares against a known-good snapshot

If all six checks pass, your upgrade is much less likely to corrupt live state.


Conclusion

Storage layout diff testing is one of the simplest ways to make upgradeable Solidity systems safer. It turns a fragile manual review problem into a repeatable automated check. By comparing compiler-emitted layout metadata, you can detect slot shifts, packing changes, inheritance drift, and accidental state corruption before they reach production.

For teams building proxies, governance-controlled upgrades, or long-lived contracts, this kind of test should be part of the standard development workflow, not an afterthought.

Learn more with useful resources