If you have worked with upgradeable Solidity contracts for a while, you probably have a slightly unhealthy fear of state variables.
Adding a function? Fine.
Changing some internal logic? Usually fine.
Adding one innocent-looking variable to the wrong contract?
That can destroy your storage layout.
I have always found this to be one of the stranger parts of upgradeable contract development. The proxy itself is not that difficult to understand. delegatecall runs implementation code while keeping the proxy's storage.
The dangerous part is what happens months later when V2, V3 and V4 start touching that same storage.
For years, one of the common answers was storage gaps.
OpenZeppelin 5 has moved to something much cleaner for its upgradeable contracts: ERC-7201 namespaced storage. And with Solidity 0.8.35, the Solidity compiler now understands the ERC-7201 storage-slot formula through a native erc7201() builtin.
I think this is worth understanding even if you normally let OpenZeppelin handle upgrades for you, because it changes how we should think about contract storage.
The actual problem is storage
Start with a normal Solidity contract:
contract Vault {
address public owner;
uint256 public totalDeposits;
}
Solidity assigns storage sequentially.
Roughly:
slot 0 -> owner
slot 1 -> totalDeposits
Simple enough.
Now imagine Vault is running behind a proxy.
The proxy owns the storage. The implementation contains the logic.
calls
User --------------------> Proxy
|
| delegatecall
v
Implementation
Storage stays inside Proxy
Suppose version one has this:
contract VaultV1 {
address public owner;
uint256 public totalDeposits;
}
And later somebody deploys this as V2:
contract VaultV2 {
address public treasury;
address public owner;
uint256 public totalDeposits;
}
We have a problem.
owner used to live in slot 0.
Now treasury expects slot 0 and owner expects slot 1.
The implementation changed, but the proxy's existing storage did not magically reorganize itself.
What V2 thinks is treasury may actually be the old owner.
What V2 thinks is owner may be part of the old totalDeposits.
That is why upgradeable storage rules are so strict. Existing variables generally cannot just be reordered, removed or have their types changed. New variables normally have to be appended in a storage-compatible way.
Inheritance makes this even more annoying.
The old storage gap approach
If you have looked through older upgradeable contracts, you have probably seen something like this:
uint256[50] private __gap;
It looks weird the first time you see it.
The idea is pretty clever though.
A base contract reserves a chunk of storage for future variables.
Imagine:
abstract contract RewardsModule {
uint256 public rewardRate;
uint256[49] private __gap;
}
Later, the contract needs another variable.
abstract contract RewardsModule {
uint256 public rewardRate;
address public rewardToken;
uint256[48] private __gap;
}
Instead of pushing everything after the contract into new storage locations, we consume one of the slots we deliberately reserved.
OpenZeppelin's upgrade documentation still explains this pattern and its upgrade tooling can validate whether the gap has been adjusted correctly.
Storage gaps work.
But they are also bookkeeping.
You have to think about how many slots were reserved, how packing affects the slot count, which parent contract owns which variables and how changes to inheritance affect everything downstream.
On a small contract, this might not bother you.
On a protocol with several layers of inherited contracts, it gets harder to reason about.
That is where namespaced storage starts making a lot more sense.
ERC-7201 changes the mental model
ERC-7201 takes a different approach.
Instead of treating contract storage as one long sequence owned by the entire inheritance tree, related variables can be grouped into a struct.
That struct gets its own namespace.
For example:
/// @custom:storage-location erc7201:myapp.storage.Rewards
struct RewardsStorage {
uint256 rewardRate;
address rewardToken;
mapping(address => uint256) pendingRewards;
}
myapp.storage.Rewards identifies this particular storage namespace.
Instead of assuming this struct begins at slot 0, ERC-7201 derives a pseudorandom storage location from that namespace.
The standard defines the location as:
keccak256(
keccak256(namespace) - 1
) & ~0xff
More precisely, ERC-7201 standardizes the formula and the @custom:storage-location erc7201:<NAMESPACE_ID> annotation so tools can understand what the developer intends.
Now our storage starts looking less like this:
Contract storage
slot 0
slot 1
slot 2
slot 3
slot 4
...
and more like this:
Proxy storage
|
+---------------+---------------+
| | |
v v v
Rewards namespace Access namespace Vault namespace
| | |
rewardRate roles deposits
rewardToken admins balances
pendingRewards
Each module gets its own part of the storage space.
That separation is the interesting part.
Why not just use keccak256("something")?
You might reasonably ask why we need an ERC for this.
Developers have been using hashed storage locations for years.
Diamond storage patterns already did something similar.
The problem is that everybody inventing their own version makes tooling harder and leaves more room for mistakes.
ERC-7201 gives tools and developers a common convention.
A namespace is represented by a struct, and the struct can be annotated:
/// @custom:storage-location erc7201:myapp.storage.Vault
struct VaultStorage {
uint256 totalDeposits;
}
The namespace ID should be unique within the contract and its inherited contracts. OpenZeppelin uses IDs such as openzeppelin.storage.ERC20 and openzeppelin.storage.Ownable for its own modules.
Your application could follow a similar convention:
myprotocol.storage.Vault
myprotocol.storage.Rewards
myprotocol.storage.Staking
myprotocol.storage.Access
The exact naming scheme matters less than consistency and uniqueness.
What OpenZeppelin 5 changed
This is where ERC-7201 becomes more than an interesting EIP.
OpenZeppelin adopted namespaced storage in the upgradeable variant of OpenZeppelin Contracts starting with version 5.0.
If you browse modern OpenZeppelin upgradeable contracts, you will see patterns that look roughly like this:
/// @custom:storage-location erc7201:openzeppelin.storage.SomeModule
struct SomeModuleStorage {
uint256 value;
address account;
}
and an internal function that returns a storage pointer:
function _getSomeModuleStorage()
private
pure
returns (SomeModuleStorage storage $)
{
assembly {
$.slot := SOME_MODULE_STORAGE_LOCATION
}
}
Then instead of directly accessing:
value = 10;
the implementation retrieves its namespace:
SomeModuleStorage storage $ = _getSomeModuleStorage();
$.value = 10;
That $ variable is not magic Solidity syntax. It is just a variable name OpenZeppelin uses for the storage pointer.
The interesting bit is the slot assigned inside assembly.
Each module knows where its own storage begins.
This fixes a nasty inheritance problem
Imagine this inheritance structure:
AccessControl
|
Rewards
|
App
With traditional sequential storage, parent variables contribute to the storage layout of the final contract.
Changing inheritance can therefore change where variables land.
With properly separated ERC-7201 namespaces, Rewards can own:
myapp.storage.Rewards
while access control owns another namespace.
Their storage no longer depends on both contracts lining up nicely in one shared sequential layout.
OpenZeppelin says its namespaced storage approach allows new variables to be added to modules without compromising existing deployments. It can also allow inheritance order changes without affecting the resulting storage layout, provided all inherited contracts involved use namespaced storage.
That last condition matters.
ERC-7201 does not magically make every inheritance change safe.
What Solidity 0.8.35 added
There is an interesting timeline here.
ERC-7201 was created in 2023.
OpenZeppelin Contracts Upgradeable adopted the pattern starting with version 5.0.
But developers still had to calculate the ERC-7201 storage location themselves and normally store the resulting hash as a constant.
Then Solidity 0.8.35 arrived in April 2026.
It added:
erc7201(string memory id)
The compiler calculates the ERC-7201 base storage slot at compile time. Solidity describes this as its first "comptime builtin."
That means something like this becomes possible:
uint256 private constant REWARDS_STORAGE_LOCATION =
erc7201("myapp.storage.Rewards");
rather than manually calculating and pasting a large hexadecimal value.
The function executes during compilation, so this is not a runtime storage calculation that users pay gas for.
Solidity also allows the builtin inside its newer custom storage layout syntax:
contract Rewards
layout at erc7201("myapp.storage.Rewards")
{
uint256 rewardRate;
address rewardToken;
}
The layout at feature lets a contract choose a custom base slot for its state instead of starting from the normal slot zero. Solidity's documentation explicitly allows erc7201() as that compile-time base-slot expression.
There is an important distinction here.
layout at erc7201(...) and OpenZeppelin's namespaced struct pattern are related ideas, but they are not the same API.
OpenZeppelin's upgradeable contracts organize individual modules around ERC-7201 storage structs. Solidity's layout at shifts the storage layout of a contract and its inherited state to another base location.
I would not blindly replace one pattern with the other.
What an application module can look like
A simplified namespaced module using modern Solidity could look conceptually like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.35;
abstract contract RewardsStorageModule {
/// @custom:storage-location erc7201:myapp.storage.Rewards
struct RewardsStorage {
uint256 rewardRate;
address rewardToken;
mapping(address => uint256) pendingRewards;
}
uint256 private constant REWARDS_STORAGE_LOCATION =
erc7201("myapp.storage.Rewards");
function _getRewardsStorage()
internal
pure
returns (RewardsStorage storage $)
{
assembly {
$.slot := REWARDS_STORAGE_LOCATION
}
}
}
Your actual contract can then use the module:
contract Rewards is RewardsStorageModule {
function setRewardRate(uint256 newRate) external {
RewardsStorage storage $ = _getRewardsStorage();
$.rewardRate = newRate;
}
function rewardRate() external view returns (uint256) {
RewardsStorage storage $ = _getRewardsStorage();
return $.rewardRate;
}
}
Later, V2 might need another variable:
struct RewardsStorage {
uint256 rewardRate;
address rewardToken;
mapping(address => uint256) pendingRewards;
uint256 totalDistributed;
}
We append it inside the same namespace.
Other modules do not need to move.
ERC-7201 does not mean "storage safety solved"
This is probably the most important part of the article.
Namespaces isolate storage.
They do not give us permission to randomly mutate the contents of a namespace.
This is still dangerous:
// V1
struct RewardsStorage {
uint256 rewardRate;
address rewardToken;
}
Then:
// BAD V2
struct RewardsStorage {
address rewardToken;
uint256 rewardRate;
}
You reordered existing state.
The namespace still points to the exact same storage location.
So the old data is still sitting there in its original layout while your new implementation interprets it differently.
Namespaced storage fixes one class of upgrade problems. It does not remove normal storage compatibility rules inside each namespace.
OpenZeppelin's Upgrades plugins understand ERC-7201 annotations and validate changes inside namespaces using their normal storage upgrade safety rules. Solidity 0.8.20 or newer is required for that validation because earlier compiler output does not contain enough information about these annotations.
This is why I would still use the OpenZeppelin upgrade validation tooling rather than treating ERC-7201 as an excuse to become adventurous with storage.
There is another easy mistake
Look closely at this:
/// @custom:storage-location erc7201:myapp.storage.Rewards
and this:
erc7201("myapp.storage.Rewards")
Those strings need to describe the same namespace.
The annotation documents the namespace for tooling.
The actual slot calculation controls where your contract reads and writes.
ERC-7201's specification notes that the annotation itself does not force the compiler to use that location. Developers are still responsible for implementing the layout correctly.
So this would be a terrible typo:
/// @custom:storage-location erc7201:myapp.storage.Rewards
uint256 private constant STORAGE_LOCATION =
erc7201("myapp.storage.Reward");
One missing s.
Now your annotation describes one namespace while the contract accesses another.
The new Solidity builtin removes the need to manually reproduce the hashing formula. It does not remove the need to review your namespace strings.
Should we stop using __gap?
For new modular upgradeable systems, I would strongly consider ERC-7201 namespaced storage.
The code is easier for me to reason about because storage ownership becomes explicit.
The rewards module owns rewards storage.
The staking module owns staking storage.
The permissions module owns permissions storage.
I do not have to mentally flatten an entire inheritance tree before understanding whether adding a variable is safe.
That said, I would not turn "storage gaps are old" into "delete every __gap from your existing system."
Existing upgradeable deployments have an existing layout.
Their history matters.
Migrating a live OpenZeppelin 4.x system to 5.x deserves particular care. OpenZeppelin explicitly says storage layouts should not be assumed compatible across major versions, and gives upgrading from 4.9.3 to 5.0.0 as an example of something that is unsafe to assume.
ERC-7201 is a better architecture for many new contracts.
It is not a time machine.
Why I like this pattern
Most improvements in Solidity are obvious when you read the code.
Custom errors save gas.
Transient storage gives us transaction-scoped state.
New opcodes give us new capabilities.
ERC-7201 is different. The improvement is mostly organizational.
And that is exactly why I think it matters.
Upgradeable contracts tend to become difficult after they have lived for a few years. There are multiple implementations, several inherited modules, new developers touching the code and real money sitting behind a proxy that cannot afford a storage mistake.
At that point, having storage explicitly separated into:
protocol.storage.Vault
protocol.storage.Rewards
protocol.storage.Staking
protocol.storage.Access
is easier to reason about than:
slots 0 to 8 belong to this contract
slots 9 to 57 are reserved
then a parent uses...
wait, did this uint128 pack with the address?
The EVM is still just reading and writing 256-bit storage slots.
ERC-7201 does not change that.
What changed is how we organize those slots and how our tools understand them.
Sometimes the boring changes are the ones that save you from the nastiest bugs.
One last thing
If I were starting a new upgradeable Solidity project today, my checklist would look roughly like this:
Use @openzeppelin/contracts-upgradeable rather than the normal package for contracts designed to sit behind proxies.
Keep state grouped into clearly named ERC-7201 namespaces.
Never reuse namespace IDs across inherited modules.
Append new variables rather than reordering existing fields inside a namespace.
Run OpenZeppelin's upgrade validation before every implementation upgrade.
And if the codebase can use Solidity 0.8.35 or newer, use the compiler's native erc7201() calculation instead of manually recreating the formula.
The proxy pattern did not suddenly become safe.
We just have a much better way to organize the part that was easiest to break.