Solidity Initializers: initialize, reinitializer, and Versioned Upgrades
You deploy an upgradeable contract. It works. Six weeks later, you add a new feature that requires a new storage variable with a default value. You deploy the upgraded implementation behind the same proxy.
The new variable reads as zero. Your protocol breaks.
The problem: constructors don't run on upgradeable contracts, and initialize() already ran once. You need something that can run exactly once per upgrade, setting up new state without touching old state. That's reinitializer.
Table of Contents
- Why Upgradeable Contracts Can't Use Constructors
- The initializer Modifier: Constructor Replacement
- What Happens When You Add Storage in V2
- The reinitializer Modifier
- How OpenZeppelin Tracks Versions Internally
- Real Code: Three Initializers in a Lending Protocol
- Calling Initializers During Upgrades
- The _disableInitializers Safety Net
- Common Mistakes
- Takeaways
Why Upgradeable Contracts Can't Use Constructors
In a normal (non-upgradeable) contract, the constructor runs once at deployment and sets up initial state:
contract Token {
address public owner;
constructor(address owner_) {
owner = owner_; // Runs once, stored in this contract's storage
}
}
With a proxy pattern, things are different. The proxy holds all the storage. The implementation holds all the logic. Users call the proxy, and the proxy delegatecalls the implementation.
Here's the issue: when you deploy the implementation contract, its constructor runs against the implementation's own storage, not the proxy's. So owner gets set in storage that nobody reads. The proxy's owner slot stays zero.
Implementation deploy:
constructor() runs → writes to Implementation storage ← nobody reads this
User calls Proxy:
Proxy delegatecalls Implementation
Implementation code reads Proxy storage → owner = 0 ← oops
Constructors are useless in this architecture. They run on the wrong storage.
The initializer Modifier: Constructor Replacement
The fix is a regular function that acts like a constructor but runs in the proxy's context:
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract MyContractV1 is Initializable, UUPSUpgradeable {
uint256 public fee;
address public admin;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address admin_, uint256 fee_) external initializer {
admin = admin_;
fee = fee_;
}
}
The initializer modifier (from OpenZeppelin's Initializable) guarantees this function can only execute once. The first call succeeds and sets a version counter to 1. Every subsequent call reverts with InvalidInitialization().
At deployment, you call initialize() atomically with the proxy creation:
// Deploy implementation
MyContractV1 impl = new MyContractV1();
// Deploy proxy and call initialize in one transaction
ERC1967Proxy proxy = new ERC1967Proxy(
address(impl),
abi.encodeCall(MyContractV1.initialize, (adminAddr, 500))
);
The proxy constructor delegatecalls the initialize function, which runs against the proxy's storage. admin and fee are stored in the proxy. Everything works.
What Happens When You Add Storage in V2
Fast forward. You're adding a feature that needs a new storage variable. Say you need a maxDuration parameter with a default value of 3650 days.
You write V2:
contract MyContractV2 is Initializable, UUPSUpgradeable {
uint256 public fee; // Slot N (existing)
address public admin; // Slot N+1 (existing)
uint256 public maxDuration; // Slot N+2 (NEW)
}
You deploy the new implementation and call upgradeToAndCall(newImpl, 0x) on the proxy. The proxy now delegates to V2's code.
What's maxDuration? Zero. Not 3650 days. Zero.
The proxy's storage slot for maxDuration was never written to. No constructor ran (can't). And initialize() already executed at V1 deployment. Calling it again reverts. The initializer modifier enforces one-time execution, permanently.
You have three options:
-
Call a separate setter after the upgrade (
setMaxDuration(3650 days)). Works, but creates a window where the value is zero and the protocol could misbehave. -
Write migration logic into a function and call it via
upgradeToAndCall. But if it's a plain function, anyone could call it again, or it could be called out of order. -
Use
reinitializer. This is what it's for.
The reinitializer Modifier
reinitializer(n) works like initializer but with a version number. It runs once per version, and only if the contract hasn't already been initialized to version n or higher.
contract MyContractV2 is Initializable, UUPSUpgradeable {
uint256 public fee;
address public admin;
uint256 public maxDuration;
function initializeV2() external reinitializer(2) {
maxDuration = 3650 days;
}
}
The guard logic inside reinitializer(2):
modifier reinitializer(uint64 version) {
InitializableStorage storage $ = _getInitializableStorage();
if ($._initializing || $._initialized >= version) {
revert InvalidInitialization();
}
$._initialized = version; // Sets version to 2
$._initializing = true;
_;
$._initializing = false;
emit Initialized(version);
}
Three things happen:
- Check: is
_initialized >= 2? If yes, revert. This prevents callinginitializeV2()twice. - Set:
_initialized = 2. Now the contract knows it's at version 2. - Execute: your initialization code runs (setting
maxDuration).
If you later add V3 with another new field:
function initializeV3() external reinitializer(3) {
newField = DEFAULT_VALUE;
}
The version check (_initialized >= 3) ensures this only runs if V2 has completed. You can skip versions too. Calling reinitializer(5) on a contract at version 2 works fine. It jumps from 2 to 5 and versions 3 and 4 are skipped permanently.
How OpenZeppelin Tracks Versions Internally
OpenZeppelin stores two values in an ERC-7201 namespaced storage slot (so it won't collide with your contract's storage):
struct InitializableStorage {
uint64 _initialized; // Version counter (1, 2, 3, ...)
bool _initializing; // Reentrancy guard for init functions
}
// Storage slot: keccak256("openzeppelin.storage.Initializable") - 1, masked
bytes32 private constant INITIALIZABLE_STORAGE =
0xf0c57e16840df040f15088dc2f81fe391c3923bec73e23a9662efc9c229c6a00;
_initialized: The highest version number that has been executed. Starts at 0 (uninitialized). After initializer, it's 1. After reinitializer(2), it's 2. The type is uint64, so you can have up to 2^64 versions (18 quintillion). More than enough.
_initializing: A reentrancy guard. Set to true while an initializer is executing, false otherwise. This prevents nested initializer calls.
The initializer modifier is equivalent to reinitializer(1) with one extra feature: it also works inside constructors. This lets non-upgradeable contracts use Initializable by calling init functions from their constructor. In production behind a proxy, they behave identically.
Real Code: Three Initializers in a Lending Protocol
Here's how this plays out in a real UUPS upgradeable lending protocol with ERC-7201 namespaced storage. The protocol has been through three versions, each adding new configurable parameters.
initialize(): V1 Deployment
The first deployment sets up everything: roles, addresses, and default parameters.
function initialize(
address admin_,
address protocolAdmin_,
address guardian_,
address upgrader_,
address feeRecipient_,
address priceOracle_,
address hookExecutor_,
address logicsManager_
) external initializer {
// Initialize OpenZeppelin modules
__AccessControl_init();
__UUPSUpgradeable_init();
__ReentrancyGuard_init();
// Grant roles
_grantRole(Roles.DEFAULT_ADMIN_ROLE, admin_);
_grantRole(Roles.PROTOCOL_ADMIN_ROLE, protocolAdmin_);
_grantRole(Roles.GUARDIAN_ROLE, guardian_);
_grantRole(Roles.UPGRADER_ROLE, upgrader_);
// Validate critical addresses
if (priceOracle_ == address(0)) revert ErrorsLib.ZeroAddress();
if (feeRecipient_ == address(0)) revert ErrorsLib.ZeroAddress();
// Set protocol config using ERC-7201 namespaced storage
LendingStorageLib.LendingStorage storage $ = LendingStorageLib._getLendingStorage();
$.priceOracle = priceOracle_;
$.feeRecipient = feeRecipient_;
$.logicsManager = logicsManager_;
$.hookExecutor = hookExecutor_;
$.flashloanFeeBps = DEFAULT_FLASHLOAN_FEE_BPS;
$.withdrawalBufferBps = DEFAULT_WITHDRAWAL_BUFFER_BPS;
$.minLtvGapBps = DEFAULT_MIN_LTV_GAP_BPS;
}
initializer sets _initialized = 1. This function can never execute again on this proxy.
Notice the __AccessControl_init() and similar __ContractName_init() calls. These are OpenZeppelin's own initializer functions for their upgradeable modules. They use the onlyInitializing modifier, which means they can only run inside an initializer or reinitializer context. You can't call them standalone.
initializeV2(): Grace Period Feature
Weeks later, the protocol adds a grace period feature. Borrowers get a configurable window after loan maturity before they can be liquidated. Two new storage fields need defaults:
// In LendingStorage struct (ERC-7201 namespaced):
// uint256 minGracePeriod; // NEW - uses gap slot
// uint256 maxGracePeriod; // NEW - uses gap slot
// uint256[46] __gap; // was [48], reduced by 2
function initializeV2() external reinitializer(2) {
LendingStorageLib.LendingStorage storage $ = LendingStorageLib._getLendingStorage();
$.minGracePeriod = DEFAULT_MIN_GRACE_PERIOD; // 1 day
$.maxGracePeriod = DEFAULT_MAX_GRACE_PERIOD; // 30 days
}
reinitializer(2) checks _initialized >= 2 (it's 1, so it passes), sets _initialized = 2, then runs the body.
This is called atomically during the upgrade:
// Safe multisig calls:
proxy.upgradeToAndCall(
newImplementation,
abi.encodeCall(LendingIntentMatcherUpgradeable.initializeV2, ())
);
upgradeToAndCall does two things in one transaction: swaps the implementation pointer AND executes initializeV2() via delegatecall. The new storage fields are set before any external caller can interact with the upgraded contract. No window where the values are zero.
initializeV3(): Max Loan Duration Cap
Later, a security audit reveals that loan.duration has no upper bound. Setting maxDuration = type(uint256).max causes arithmetic overflow in health checks, permanently disabling liquidation. The fix adds a configurable cap:
// In LendingStorage struct:
// uint256 maxLoanDuration; // NEW - uses gap slot
// uint256[45] __gap; // was [46], reduced by 1
function initializeV3() external reinitializer(3) {
LendingStorageLib.LendingStorage storage $ = LendingStorageLib._getLendingStorage();
$.maxLoanDuration = DEFAULT_MAX_LOAN_DURATION; // 3650 days (10 years)
}
Same pattern. reinitializer(3) checks _initialized >= 3 (it's 2), passes, sets _initialized = 3, runs the body.
The upgrade call:
bytes memory initData = abi.encodeCall(
LendingIntentMatcherUpgradeable.initializeV3, ()
);
proxy.upgradeToAndCall(newImplementation, initData);
If someone tries to call initializeV3() again (accidentally or maliciously), _initialized is already 3, so _initialized >= 3 is true and it reverts with InvalidInitialization().
Calling Initializers During Upgrades
There are two ways to execute a reinitializer. One is safe for production. The other creates a vulnerability window.
Safe: atomic via upgradeToAndCall (recommended)
proxy.upgradeToAndCall(newImpl, abi.encodeCall(Contract.initializeV2, ()));
The implementation swap and the initializer call happen in the same transaction. No external caller can interact with the contract between the two operations.
Risky: separate transactions
// TX 1: Upgrade
proxy.upgradeToAndCall(newImpl, "");
// TX 2: Initialize (separate call)
proxy.initializeV2();
Between TX 1 and TX 2, the contract runs on the new code but with uninitialized new fields. If the new code reads maxLoanDuration (which is 0), validation checks like if (maxDuration > maxLoanDuration) would reject every intent, effectively bricking the protocol until TX 2 completes.
For multisig-controlled protocols, both calls should be in the same Safe batch transaction.
The _disableInitializers Safety Net
Every upgradeable contract should call _disableInitializers() in its constructor:
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
This sets _initialized = type(uint64).max (the maximum possible version) on the implementation contract's own storage. It doesn't affect the proxy (the proxy has its own storage). It prevents anyone from calling initialize() directly on the deployed implementation.
Why does this matter? Without it, an attacker could call initialize() on the implementation contract, becoming the admin of the implementation. While this doesn't directly affect the proxy's state, it can be exploited in some upgrade patterns where the implementation's storage is read.
The @custom:oz-upgrades-unsafe-allow constructor comment tells OpenZeppelin's upgrade checker tool that this constructor is intentional and safe.
Common Mistakes
Mistake 1: Forgetting to call the reinitializer during upgrade.
You deploy V2 with upgradeToAndCall(newImpl, 0x) (empty data). The new storage fields stay at zero. If your code assumes non-zero defaults, things break silently.
// V2 code expects maxDuration to be bounded
if (intent.maxDuration > $.maxLoanDuration) revert DurationExceedsMax();
// maxLoanDuration is 0, so EVERY intent reverts
Prevention: always test the full upgrade flow (deploy + initialize) in your test suite. Verify new fields have correct values after upgrade.
Mistake 2: Using initializer instead of reinitializer for V2+.
// WRONG: this will revert because initializer already ran at V1
function initializeV2() external initializer {
$.newField = DEFAULT_VALUE;
}
The initializer modifier checks _initialized == 0. After V1, _initialized is 1. This always reverts. Use reinitializer(2) for V2, reinitializer(3) for V3, and so on.
Mistake 3: Adding access control to reinitializers.
// UNNECESSARY: reinitializer(n) is already a one-time guard
function initializeV2() external onlyRole(ADMIN_ROLE) reinitializer(2) {
$.newField = DEFAULT_VALUE;
}
The reinitializer(n) modifier already ensures the function can only run once. Adding onlyRole means only the admin can call it, but since it can only run once and only sets hardcoded defaults, there's no attack even if a non-admin calls it first. The access control adds gas cost and complexity without security benefit. OpenZeppelin's own examples don't use access control on reinitializers.
That said, if your reinitializer accepts parameters (not hardcoded defaults), then access control is appropriate because a malicious caller could set harmful values.
Mistake 4: Reordering or removing storage fields between versions.
// V1 storage
struct Storage {
uint256 fee; // Slot 0
address admin; // Slot 1
uint256[48] __gap;
}
// V2 storage (WRONG: reordered!)
struct Storage {
address admin; // Slot 0 ← was fee!
uint256 fee; // Slot 1 ← was admin!
uint256 newField; // Slot 2
uint256[47] __gap;
}
After upgrade, admin reads from what was the fee slot. Everything corrupts. Always add new fields at the end, before the gap. Reduce the gap by the number of fields added.
Takeaways
Use initializer for V1 deployment. It replaces the constructor, runs once, and sets _initialized = 1.
Use reinitializer(n) when upgrades add storage that needs defaults. Version numbers are sequential but can skip. Each version runs at most once.
Call reinitializers atomically with upgradeToAndCall. Never leave a window where new code runs with uninitialized storage.
Call _disableInitializers() in the constructor. Prevents initialization of the implementation contract directly.
Always add new storage fields at the end of the struct. Reduce the __gap array by the number of fields added. Never reorder, never remove.
The version counter is your safety net. It ensures each migration step runs exactly once, in the right order, no matter who calls it or when. Constructors gave you this for free in non-upgradeable contracts. In upgradeable contracts, you build it yourself with initializer and reinitializer.