Web3 Development
Upgradeable Smart Contracts: Proxy Patterns Explained
TL;DR
Smart contracts are immutable by default, but production systems often need the ability to fix bugs, add features, or respond to changing requirements after deployment. Proxy patterns solve this by separating storage from logic — a proxy contract holds the state and delegates calls to an implementation contract that can be swapped. I use the UUPS pattern for most client projects because it keeps upgrade logic in the implementation contract itself, reducing gas costs and giving the team explicit control over who can trigger upgrades. This article covers the three major proxy patterns (Transparent, UUPS, Beacon), the storage layout rules that will save you from catastrophic data corruption, initialization patterns that replace constructors, and the honest trade-offs of making a contract upgradeable at all. Every pattern includes working Solidity code built on OpenZeppelin 5.x. If you are building contracts that real users will depend on, you need to understand these patterns — and more importantly, you need to understand when NOT to use them.
Why Upgradeable Contracts
The first smart contract I deployed for a client had a bug in the reward calculation. It was off by a factor of ten in an edge case that only appeared when a user staked exactly the maximum amount. The contract was not upgradeable. We had to deploy a new contract, migrate all user balances manually, and convince every user to approve the new address. It took two weeks and cost the client real credibility.
That experience is why I take upgradeability seriously — not because every contract needs it, but because the ones that do really need it.
Here is the reality of smart contract development:
- Bugs happen. Even with comprehensive fuzz testing and audits, edge cases slip through. The ability to patch a live contract without migrating state is not a luxury — it is risk management.
- Requirements change. A lending protocol might need to support a new collateral type six months after launch. A governance system might need a new voting mechanism. Business logic evolves.
- Regulatory pressure. Compliance requirements in DeFi are shifting fast. A protocol that cannot adapt its KYC hooks or fee structures without redeployment is a protocol that might get shut down.
- Gas efficiency improvements. Solidity and the EVM are evolving. Being able to deploy optimized logic without changing the contract address saves your users from re-approving tokens and updating integrations.
The core mechanism behind every proxy pattern is delegatecall. When Contract A uses delegatecall to call Contract B, the code from B executes in the context of A — meaning it reads and writes A's storage, uses A's msg.sender, and operates on A's balance. This is the primitive that makes upgradeable contracts possible.
// The fundamental mechanism — proxy delegates to implementation
// Storage lives in the proxy. Logic lives in the implementation.
//
// User -> Proxy (storage) --delegatecall--> Implementation (logic)
// ^
// | Can be swapped
// v
// New Implementation (logic v2)The proxy contract has a fallback function that catches every call and forwards it via delegatecall to whatever implementation address is stored in a specific storage slot. When you upgrade, you change that stored address. Same proxy, same storage, new logic.
Transparent Proxy Pattern
The Transparent Proxy was the first widely adopted upgrade pattern, popularized by OpenZeppelin. It solves a specific problem: function selector clashing between the proxy and the implementation.
Consider this: if both the proxy and the implementation have a function called upgrade(), which one gets called? The Transparent Proxy pattern solves this by checking msg.sender. If the caller is the admin, calls go to the proxy's admin functions. If the caller is anyone else, calls get delegated to the implementation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ProxyAdmin} from "@openzeppelin/contracts/proxy/transparent/ProxyAdmin.sol";
import {TransparentUpgradeableProxy} from "@openzeppelin/contracts/proxy/transparent/TransparentUpgradeableProxy.sol";
/// @title MyTokenV1 — Implementation behind a Transparent Proxy
/// @notice This is the logic contract. Storage lives in the proxy.
contract MyTokenV1 {
// Storage variables — these live in the proxy's storage
mapping(address => uint256) private _balances;
uint256 private _totalSupply;
string private _name;
string private _symbol;
bool private _initialized;
/// @notice Replaces the constructor for upgradeable contracts
function initialize(
string calldata name_,
string calldata symbol_
) external {
require(!_initialized, "Already initialized");
_name = name_;
_symbol = symbol_;
_initialized = true;
}
function mint(address to, uint256 amount) external {
_balances[to] += amount;
_totalSupply += amount;
}
function balanceOf(address account) external view returns (uint256) {
return _balances[account];
}
function totalSupply() external view returns (uint256) {
return _totalSupply;
}
}Deploying a Transparent Proxy with OpenZeppelin 5.x looks like this:
// Deploy the implementation
MyTokenV1 implementation = new MyTokenV1();
// Deploy the proxy pointing to the implementation
// The ProxyAdmin is deployed automatically by TransparentUpgradeableProxy
TransparentUpgradeableProxy proxy = new TransparentUpgradeableProxy(
address(implementation),
adminAddress, // The address that can upgrade
abi.encodeCall(MyTokenV1.initialize, ("MyToken", "MTK"))
);
// Users interact with the proxy address
MyTokenV1 token = MyTokenV1(address(proxy));
token.mint(msg.sender, 1000);The Transparent Proxy deploys a separate ProxyAdmin contract that becomes the actual admin of the proxy. This is a deliberate design choice — it means no EOA directly controls upgrades, and you can transfer admin ownership to a multi-sig or a governance timelock.
The trade-off: Every single call to the proxy must check whether msg.sender is the admin. That is an extra SLOAD on every transaction. For high-throughput contracts that get called thousands of times per day, this gas overhead adds up. It is not enormous — roughly 2,100 gas per call for the storage read — but it is unnecessary overhead for contracts where the admin distinction could be handled differently.
UUPS Proxy Pattern
The Universal Upgradeable Proxy Standard (UUPS, EIP-1822) is what I use for most client projects. The key difference from the Transparent Proxy: the upgrade logic lives in the implementation contract, not the proxy.
This might seem like a minor distinction, but it changes everything:
- The proxy is simpler and cheaper to deploy. No admin checking logic, no separate ProxyAdmin contract.
- Gas is lower per call. No
msg.sendercheck on every transaction. - The implementation controls its own upgradeability. You can add custom authorization, timelocks, or even make a contract non-upgradeable by deploying an implementation that removes the upgrade function.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @title VaultV1 — UUPS Upgradeable Vault
/// @notice Accepts ETH deposits with per-user tracking
contract VaultV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
mapping(address => uint256) public deposits;
uint256 public totalDeposits;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
}
function deposit() external payable {
require(msg.value > 0, "Zero deposit");
deposits[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount, "Insufficient balance");
deposits[msg.sender] -= amount;
totalDeposits -= amount;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "ETH transfer failed");
}
/// @notice UUPS requires this — only owner can upgrade
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}When you need to upgrade, you deploy a new implementation and call upgradeToAndCall on the proxy:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
/// @title VaultV2 — Adds withdrawal fee
/// @notice Storage layout must be identical to V1 + new variables appended
contract VaultV2 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
// V1 storage — DO NOT MODIFY ORDER OR TYPES
mapping(address => uint256) public deposits;
uint256 public totalDeposits;
// V2 additions — appended after V1 storage
uint256 public withdrawalFeeBps;
uint256 public collectedFees;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers();
}
function reinitialize(
uint256 feeBps
) public reinitializer(2) {
withdrawalFeeBps = feeBps;
}
function deposit() external payable {
require(msg.value > 0, "Zero deposit");
deposits[msg.sender] += msg.value;
totalDeposits += msg.value;
}
function withdraw(uint256 amount) external {
require(deposits[msg.sender] >= amount, "Insufficient balance");
uint256 fee = (amount * withdrawalFeeBps) / 10_000;
uint256 payout = amount - fee;
deposits[msg.sender] -= amount;
totalDeposits -= amount;
collectedFees += fee;
(bool sent, ) = msg.sender.call{value: payout}("");
require(sent, "ETH transfer failed");
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}// Upgrade process
VaultV2 newImplementation = new VaultV2();
// Call upgradeToAndCall on the proxy (which delegates to V1's UUPS logic)
VaultV1(proxyAddress).upgradeToAndCall(
address(newImplementation),
abi.encodeCall(VaultV2.reinitialize, (50)) // 0.5% fee
);Critical risk with UUPS: If you deploy an implementation that does not include the _authorizeUpgrade function (or inherits from a non-upgradeable base), the proxy becomes permanently non-upgradeable. There is no fallback. This is why OpenZeppelin 5.x requires you to explicitly override _authorizeUpgrade — it is a guardrail against bricking your proxy. I have seen one team lose a contract this way on testnet. Better testnet than mainnet.
Beacon Proxy
The Beacon Proxy pattern solves a different problem: what if you have dozens or hundreds of proxy contracts that all need to point to the same implementation?
Think of a factory that creates individual vaults for each user, or a protocol that deploys a new pool contract for every trading pair. With Transparent or UUPS proxies, upgrading means calling upgrade on every single proxy individually. With a Beacon, all proxies point to a single Beacon contract that stores the implementation address. Upgrade the Beacon once, and every proxy upgrades simultaneously.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {BeaconProxy} from "@openzeppelin/contracts/proxy/beacon/BeaconProxy.sol";
import {UpgradeableBeacon} from "@openzeppelin/contracts/proxy/beacon/UpgradeableBeacon.sol";
/// @title UserVault — Individual vault logic
contract UserVault {
address public owner;
uint256 public balance;
bool private _initialized;
function initialize(address owner_) external {
require(!_initialized, "Already initialized");
owner = owner_;
_initialized = true;
}
function deposit() external payable {
require(msg.sender == owner, "Not owner");
balance += msg.value;
}
function withdraw(uint256 amount) external {
require(msg.sender == owner, "Not owner");
require(balance >= amount, "Insufficient");
balance -= amount;
(bool sent, ) = owner.call{value: amount}("");
require(sent, "Transfer failed");
}
}
/// @title VaultFactory — Creates Beacon Proxies for each user
contract VaultFactory {
UpgradeableBeacon public immutable beacon;
mapping(address => address) public userVaults;
constructor(address implementation, address admin) {
beacon = new UpgradeableBeacon(implementation, admin);
}
function createVault() external returns (address) {
require(userVaults[msg.sender] == address(0), "Vault exists");
BeaconProxy proxy = new BeaconProxy(
address(beacon),
abi.encodeCall(UserVault.initialize, (msg.sender))
);
userVaults[msg.sender] = address(proxy);
return address(proxy);
}
/// @notice Upgrade ALL user vaults at once
function upgradeImplementation(address newImpl) external {
// beacon.upgradeTo checks that msg.sender is the admin
beacon.upgradeTo(newImpl);
}
}The Beacon pattern is powerful for factory-style architectures, but it introduces a single point of failure — the Beacon contract. If the Beacon's admin key is compromised, every single proxy gets hijacked in one transaction. I always put a timelock and multi-sig on Beacon admin operations. Always.
When I use Beacon Proxy: Factory patterns where ten or more proxies share the same logic. Below that threshold, the extra indirection (each call reads the Beacon to get the implementation address before delegating) is not worth it.
Storage Layout Rules
Storage layout is where upgradeable contracts go from "straightforward" to "will destroy all user funds if you get this wrong." Here are the rules I follow without exception:
Rule 1: Never change the order of existing storage variables.
// V1 storage
contract V1 {
uint256 public totalSupply; // slot 0
mapping(address => uint256) public balances; // slot 1
}
// V2 — CORRECT: append new variables after existing ones
contract V2 {
uint256 public totalSupply; // slot 0 — unchanged
mapping(address => uint256) public balances; // slot 1 — unchanged
uint256 public fee; // slot 2 — NEW, appended
}
// V2 — WRONG: inserting a variable shifts everything
contract V2Wrong {
uint256 public totalSupply; // slot 0 — unchanged
uint256 public fee; // slot 1 — COLLISION with balances!
mapping(address => uint256) public balances; // slot 2 — shifted, data corrupted
}Rule 2: Never change the type of an existing variable.
Changing a uint256 to an address or a mapping to an array will corrupt data. The storage slot stays the same but the interpretation changes. This is silent corruption — no revert, no error, just wrong data.
Rule 3: Never remove a variable. Use a placeholder instead.
// V1
contract V1 {
uint256 public oldFeature; // slot 0
uint256 public totalSupply; // slot 1
}
// V2 — CORRECT: replace with a gap
contract V2 {
uint256 private __deprecated_oldFeature; // slot 0 — preserved
uint256 public totalSupply; // slot 1 — unchanged
uint256 public newFeature; // slot 2 — appended
}Rule 4: Use storage gaps in base contracts.
If your upgradeable contract inherits from other upgradeable contracts, each base contract should reserve storage slots using a gap. OpenZeppelin does this in all their upgradeable contracts.
contract BaseV1 {
uint256 public value;
// Reserve 49 slots for future base contract upgrades
uint256[49] private __gap;
}
contract ChildV1 is BaseV1 {
uint256 public childValue;
uint256[49] private __gap;
}
// When BaseV1 needs a new variable:
contract BaseV2 {
uint256 public value;
uint256 public newBaseValue; // Uses one gap slot
uint256[48] private __gap; // Gap shrinks by 1
}Rule 5: Use OpenZeppelin's upgrade safety tooling. The @openzeppelin/upgrades-core package validates storage layout compatibility between versions. Run it in CI. Do not rely on manual inspection. I have personally caught two layout violations that would have been catastrophic in production — both were subtle inheritance order changes that shifted slots.
Initialization Instead of Constructors
Constructors do not work with proxy patterns. When a proxy delegates calls to an implementation contract, the implementation's constructor has already run — in the implementation's own storage context, not the proxy's. The proxy never sees the constructor's effects.
The solution: replace constructors with initialize functions that are called once, immediately after proxy deployment.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
import {ERC20Upgradeable} from "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
contract MyTokenUpgradeable is
Initializable,
ERC20Upgradeable,
OwnableUpgradeable,
UUPSUpgradeable
{
uint256 public maxSupply;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
// Prevents the implementation contract from being initialized
// Only the proxy should be initialized
_disableInitializers();
}
/// @notice Called once during proxy deployment
function initialize(
string calldata name_,
string calldata symbol_,
uint256 maxSupply_,
address initialOwner
) public initializer {
// Initialize all parent contracts
__ERC20_init(name_, symbol_);
__Ownable_init(initialOwner);
__UUPSUpgradeable_init();
maxSupply = maxSupply_;
}
function mint(address to, uint256 amount) external onlyOwner {
require(totalSupply() + amount <= maxSupply, "Exceeds max supply");
_mint(to, amount);
}
function _authorizeUpgrade(
address newImplementation
) internal override onlyOwner {}
}Three things I never skip:
- `_disableInitializers()` in the constructor. Without this, someone could call
initializedirectly on the implementation contract (not the proxy) and potentially exploit it. - The `initializer` modifier. Ensures
initializecan only be called once. Calling it twice would reset ownership and other critical state. - `reinitializer(n)` for version upgrades. When V2 needs initialization, use
reinitializer(2), notinitializer. Theinitializermodifier is already consumed by V1.
OpenZeppelin Upgradeable Contracts
OpenZeppelin provides a complete suite of upgradeable versions of their standard contracts. These are not just the regular contracts with a different name — they are specifically designed for proxy compatibility.
Key differences from the standard library:
- No constructors. Every contract uses
__ContractName_init()functions. - Storage gaps in every base contract (the
__gaparrays I mentioned earlier). - The
Initializablebase contract manages initialization state. - Separate npm package:
@openzeppelin/contracts-upgradeable.
Here is my typical Foundry setup for an upgradeable project:
# Install dependencies
forge install OpenZeppelin/openzeppelin-contracts-upgradeable
forge install OpenZeppelin/openzeppelin-contracts
# remappings.txt
@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/
@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/And my deployment script pattern:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Script} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {MyTokenUpgradeable} from "../src/MyTokenUpgradeable.sol";
contract DeployScript is Script {
function run() external {
uint256 deployerKey = vm.envUint("PRIVATE_KEY");
address deployer = vm.addr(deployerKey);
vm.startBroadcast(deployerKey);
// 1. Deploy implementation
MyTokenUpgradeable implementation = new MyTokenUpgradeable();
// 2. Encode initialization call
bytes memory initData = abi.encodeCall(
MyTokenUpgradeable.initialize,
("MyToken", "MTK", 1_000_000 ether, deployer)
);
// 3. Deploy proxy with initialization
ERC1967Proxy proxy = new ERC1967Proxy(
address(implementation),
initData
);
vm.stopBroadcast();
}
}For Hardhat users, the @openzeppelin/hardhat-upgrades plugin handles the proxy deployment, storage layout validation, and upgrade safety checks automatically. I prefer Foundry for speed, but I use the OpenZeppelin upgrade safety tooling alongside it to validate storage layouts before any deployment.
When NOT to Use Proxies
This is the section most articles skip, and it is the most important one.
Upgradeability is not free. It adds complexity, introduces trust assumptions, and creates attack surface. Here are the cases where I explicitly tell clients NOT to use proxy patterns:
1. Simple tokens with fixed behavior.
An ERC-20 token that mints a fixed supply and has no admin functions does not need upgradeability. The entire value proposition is immutability — "this token will always work exactly as specified." Adding a proxy means adding an admin who could change the rules. That undermines trust.
2. Protocols where immutability IS the product.
Some DeFi protocols sell their users on the guarantee that the rules cannot change. Uniswap V2 pairs are not upgradeable. That is a feature, not a limitation. If your protocol's value comes from credible neutrality, a proxy pattern actively hurts you.
3. Contracts that handle less than $100K in TVL.
If the contract does not hold significant value, the cost of upgradeability (audit complexity, deployment gas, operational overhead) may exceed the cost of simply redeploying. A new deployment with a migration script is often simpler and cheaper.
4. When the team cannot commit to secure key management.
An upgradeable contract is only as secure as its admin key. If the upgrade authority is a single EOA instead of a multi-sig with a timelock, upgradeability is a liability. I have seen protocols where the "upgrade key" was on a developer's laptop. That is not upgradeability — that is a rug pull waiting to happen.
5. When gas costs matter more than flexibility.
Every proxy call adds overhead. The delegatecall itself costs extra gas, and the proxy has a slightly larger deployment cost. For contracts that get called millions of times (like a DEX router), that overhead compounds. Measure it. Sometimes the answer is "deploy a new version and migrate."
My rule of thumb: if the contract will hold more than $500K in user funds AND the team has a multi-sig with at least 3-of-5 signers AND the protocol needs to evolve, use UUPS. Otherwise, default to immutable contracts with a clear migration path.
Testing Upgrades
Testing upgradeable contracts requires more than testing the logic. You need to verify that the upgrade process itself works correctly — that storage is preserved, that new functions work with existing state, and that the initialization sequence is correct.
Here is my Foundry test pattern for upgrade testing:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {Test} from "forge-std/Test.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {VaultV1} from "../src/VaultV1.sol";
import {VaultV2} from "../src/VaultV2.sol";
contract UpgradeTest is Test {
VaultV1 public implementation;
ERC1967Proxy public proxy;
VaultV1 public vault; // Typed interface to proxy
address public owner = makeAddr("owner");
address public user = makeAddr("user");
function setUp() public {
implementation = new VaultV1();
bytes memory initData = abi.encodeCall(
VaultV1.initialize,
(owner)
);
proxy = new ERC1967Proxy(
address(implementation),
initData
);
vault = VaultV1(address(proxy));
}
function test_upgradePreservesState() public {
// Arrange: create state in V1
vm.deal(user, 10 ether);
vm.prank(user);
vault.deposit{value: 5 ether}();
assertEq(vault.deposits(user), 5 ether);
assertEq(vault.totalDeposits(), 5 ether);
// Act: upgrade to V2
VaultV2 newImpl = new VaultV2();
vm.prank(owner);
vault.upgradeToAndCall(
address(newImpl),
abi.encodeCall(VaultV2.reinitialize, (50))
);
// Assert: V1 state is preserved
VaultV2 vaultV2 = VaultV2(address(proxy));
assertEq(vaultV2.deposits(user), 5 ether);
assertEq(vaultV2.totalDeposits(), 5 ether);
// Assert: V2 features work
assertEq(vaultV2.withdrawalFeeBps(), 50);
}
function test_onlyOwnerCanUpgrade() public {
VaultV2 newImpl = new VaultV2();
vm.prank(user);
vm.expectRevert();
vault.upgradeToAndCall(
address(newImpl),
abi.encodeCall(VaultV2.reinitialize, (50))
);
}
function test_cannotReinitializeV1() public {
vm.expectRevert();
vault.initialize(user);
}
function testFuzz_depositSurvivesUpgrade(
uint256 amount
) public {
amount = bound(amount, 1, 100 ether);
vm.deal(user, amount);
vm.prank(user);
vault.deposit{value: amount}();
// Upgrade
VaultV2 newImpl = new VaultV2();
vm.prank(owner);
vault.upgradeToAndCall(
address(newImpl),
abi.encodeCall(VaultV2.reinitialize, (100))
);
VaultV2 vaultV2 = VaultV2(address(proxy));
assertEq(vaultV2.deposits(user), amount);
}
}My upgrade testing checklist:
- State preservation. Every storage variable from V1 must read correctly after upgrading to V2. Test with realistic data, not just zero values.
- Authorization. Only the designated admin should be able to trigger upgrades. Test that unauthorized callers revert.
- Re-initialization protection. Calling
initializeafter upgrade must revert. Callingreinitializer(1)after V2 usesreinitializer(2)must revert. - Fuzz testing with state. Deposit random amounts, upgrade, verify balances. This catches precision issues and overflow edge cases.
- Downgrade prevention. Depending on your design, you may want to prevent downgrading to an older implementation. Test this explicitly.
- Storage layout validation. Run OpenZeppelin's upgrade safety checks as part of your CI pipeline. Do not rely on visual inspection.
My Recommendation
After implementing upgradeable contracts for multiple client projects — from simple token contracts to complex DeFi vaults — here is what I recommend:
Default to UUPS. It is cheaper per transaction, gives you explicit control over upgrade authorization, and lets you make a contract permanently immutable by deploying an implementation without _authorizeUpgrade. The Transparent Proxy pattern is not wrong, but UUPS is strictly better for most use cases as of OpenZeppelin 5.x.
Use Beacon Proxy only for factories. If you are deploying ten or more instances of the same logic, Beacon saves you from managing individual upgrades. Otherwise, the extra indirection is not worth it.
Always pair upgrades with a timelock. Give your users 24-48 hours to exit before an upgrade takes effect. This is not just good practice — it is the difference between "upgradeable" and "rug-pullable" in your users' eyes.
Run storage layout checks in CI. Not manually. Not "we will remember." Automated. Every pull request. The @openzeppelin/upgrades-core package makes this straightforward.
Have an exit plan. Design your upgradeable contract so that it can be made immutable if the protocol reaches maturity. Governance should be able to vote to renounce upgrade authority permanently. The best upgrade path ends in no more upgrades.
If you are building a protocol that needs post-deployment flexibility and you want it done right — with proper access control, timelock governance, and battle-tested patterns — get in touch. I have been through the storage corruption nightmares so you do not have to.
Key Takeaways
- Proxy patterns separate storage from logic using
delegatecall, enabling logic upgrades without changing the contract address or losing state. - UUPS is the recommended pattern for most projects — cheaper gas per call, simpler proxy, and explicit upgrade authorization in the implementation.
- Transparent Proxy adds per-call overhead from admin checks but is battle-tested and widely understood.
- Beacon Proxy is for factory patterns where many proxies share the same implementation and need to be upgraded atomically.
- Storage layout rules are non-negotiable. Never reorder, retype, or remove variables. Append only. Use gaps in base contracts.
- Initialization replaces constructors. Use
_disableInitializers()in the constructor andreinitializer(n)for versioned upgrades. - Not every contract should be upgradeable. Immutability is a feature when trust and credible neutrality matter more than flexibility.
- Test the upgrade process itself, not just the logic. State preservation, authorization, and re-initialization protection are all critical.
- Timelocks and multi-sigs are mandatory for any production upgradeable contract. Without them, upgradeability is a liability.
*Written by Uvin Vindula↗ — Web3 engineer building smart contract systems from Sri Lanka and the UK. I write production Solidity for clients who need contracts that work on day one and can evolve on day one hundred. Follow my work at @IAMUVIN↗ or explore my services.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.