
Deployed code is permanent. That is the point of a blockchain, and it is also the problem.
A bug you would patch in an afternoon on a web backend can be unfixable on-chain. Funds sit in a contract nobody can change. Teams have watched it happen and had no move to make.
Upgradeability is the answer most projects reach for. Add a proxy, keep the address stable, swap the logic when you need to.
Then the second problem arrives. An upgradeable contract is one where somebody can replace the code holding your users’ money. You have traded immutability for control, and control is an attack surface.
Upgradeable smart contracts are worth it in many cases. They are only safe when the storage layout, the initialisation and the upgrade permissions are all handled properly. This guide covers the proxy patterns, the failures that recur, and the workflow that catches them before mainnet.
An upgradeable smart contract is not a contract whose code changes. Deployed bytecode is still immutable.
What changes is which contract your users are actually talking to.
The setup splits one logical contract into two deployed ones. A proxy holds the state and keeps a permanent address. An implementation holds the logic and can be replaced.
Users only ever interact with the proxy. It forwards every call to the current implementation, runs that code against its own storage, and returns the result. Upgrading means pointing the proxy at a new implementation address. The proxy address, and all the data in it, stay exactly where they were.
That distinction matters legally as well as technically. A contract whose behaviour can be changed by its operator is a different proposition from one that cannot, which is part of the wider comparison between smart contracts vs traditional contracts and how each allocates trust.
The whole pattern rests on one EVM instruction: delegatecall.
A normal external call runs the target contract’s code against the target contract’s storage.
Delegatecall runs the target’s code against the caller’s storage. It also preserves msg.sender and msg.value as the original caller saw them.
So when the proxy makes a delegatecall to the implementation, that logic executes but every state read and write lands in the proxy’s storage. The implementation’s own storage stays empty and unused.
This is why upgrading works. Replace the logic, keep the data.
Here is the catch that defines everything else in this article.
Solidity assigns storage slots by declaration order. Slot 0, slot 1, slot 2, and so on. The implementation writes to those slots in the proxy.
The proxy also needs to store something of its own: the implementation address, and usually an admin address. If the proxy naively used slot 0, and the implementation also used slot 0 for a variable, each would silently overwrite the other.
ERC-1967 standardises fixed, deliberately obscure storage slots for proxy data, including the implementation address, the admin and the beacon.
Those slots are derived from hashed strings, so they sit far away from anything Solidity allocates sequentially. Collisions become effectively impossible, and tooling such as block explorers knows where to look to identify a proxy.
Every serious proxy implementation follows ERC-1967. Writing your own slot scheme has no upside.
Three patterns dominate, and they differ mainly in where the upgrade logic lives.
The upgrade logic lives in the proxy, administered through a separate ProxyAdmin contract.
The proxy inspects the caller. Admin calls can only upgrade. Everyone else is forwarded to the implementation. That routing is how the pattern avoids function selector clashes between proxy and implementation functions.
The cost is deployment expense. Every proxy carries the upgrade machinery, which makes it heavier than the alternative.
Defined by ERC-1822, UUPS moves the upgrade logic into the implementation contract. The proxy becomes a thin forwarder.
You inherit UUPSUpgradeable and override _authorizeUpgrade with your own access control check. OpenZeppelin’s documentation states its recommendation is now shifting towards UUPS, describing the pattern as both lightweight and versatile.
There is a sharp edge. Because the upgrade logic sits in the implementation, shipping a version that omits it permanently locks the proxy. OpenZeppelin guards against this by rejecting upgrades to implementations without the UUPS mechanism, though that check can be bypassed.
The proxy reads its implementation address from a separate beacon contract rather than storing it.
The advantage is fleet management. Upgrade the beacon once and every proxy pointing at it moves in the same transaction. For a system deploying many identical contracts, this is the only pattern that scales.
The trade-off is a shared single point of control across the entire fleet.
|
Area |
Transparent |
UUPS |
Beacon |
|
Upgrade logic lives in |
The proxy |
The implementation |
The beacon |
|
Deployment cost per proxy |
Higher |
Lower |
Lower |
|
Gas per user call |
Slightly higher |
Lower |
Extra read for beacon |
|
Selector clash risk |
Handled by caller routing |
Must be managed |
Must be managed |
|
Can be bricked by a bad upgrade |
No |
Yes, if UUPS logic omitted |
Affects the whole fleet |
|
Best suited to |
Legacy and existing deployments |
Most new single contracts |
Many identical contracts |
Pattern choice also affects your build budget, since beacon systems and multi-contract upgrade paths carry more testing overhead than a single proxy, which is worth folding into your smart contract development cost estimate early.
Upgradeability is a trade-off, not an improvement. Reach for it deliberately.
That last point deserves weight. A proxy adds contracts, storage rules and permissions, and each is a place to get something wrong. For a simple token, immutable is often genuinely safer.
Context decides. A tokenised property platform handling transfers and compliance will almost certainly need to adapt, which is why upgradeability shows up so often in smart contracts in real estate and similar regulated use cases.
A middle path exists and is underused. Make the contract immutable but add a pause switch, or make only specific parameters adjustable. You get an emergency stop without the ability to rewrite the logic.
These are the failures that recur. Each has caused real losses.
The implementation is a deployed contract in its own right, and its storage is separate from the proxy’s. If nobody initialises it, an attacker can.
Taking ownership of the implementation does not directly give control of the proxy’s state, but with a UUPS implementation it can allow destructive calls that affect the proxy. OpenZeppelin is explicit about not leaving a contract uninitialised, and the mitigation is calling _disableInitializers() in the implementation’s constructor.
Change the order of state variables between versions, insert a variable in the middle, or change a type, and every variable after that point reads from the wrong slot.
Nothing reverts. The contract keeps running and returns corrupted values. A balance becomes an address. This is the most dangerous category precisely because it fails quietly.
An upgrade function without proper access control lets anyone replace your logic. In UUPS that means _authorizeUpgrade must carry a real permission check.
Two different functions can share a four-byte selector. In a proxy, a clash between proxy and implementation functions can make one unreachable.
OpenZeppelin warns specifically against adding external functions to a transparent proxy, because a clash could render upgradeToAndCall inaccessible and lock upgrades permanently.
Constructors execute once, at deployment, against the deploying contract’s storage. For an implementation behind a proxy, that is the wrong storage entirely.
Any state you set in a constructor simply does not exist from the proxy’s perspective. This is why upgradeable contracts use initialiser functions instead, and why forgetting to call one leaves a contract in an unconfigured state.
A single externally owned account controlling upgrades is one key that can rewrite your protocol. Audits flag it, and a compromised key is a total loss.
Four rules prevent most of what goes wrong.
Never reorder, remove or retype an existing variable. Only add new ones at the end.
For contracts that may be inherited from, the traditional protection is a storage gap, a reserved array such as uint256[50] private __gap; that lets a parent gain variables later without shifting a child’s layout.
OpenZeppelin’s own upgradeable contracts moved away from this in 5.x, replacing gaps with namespaced storage under ERC-7201. That approach isolates each contract’s variables into its own hashed region and removes the sequencing problem at the root. Gaps remain correct for existing 4.x-style inheritance chains, so match whichever convention your codebase already uses rather than mixing the two.
An upgradeable contract replaces its constructor with an initialiser function, guarded by the initializer modifier so it can only ever run once. That function is where you set the owner and any starting state.
The implementation still needs a constructor, but for one job only: calling _disableInitializers() so nobody can claim the implementation contract directly. OpenZeppelin’s tooling flags constructors in upgradeable contracts, so annotate that one with @custom:oz-upgrades-unsafe-allow constructor to mark it as deliberate.
Two version details catch people out, and both appear in older tutorials. OpenZeppelin 5.x removed __UUPSUpgradeable_init(), because UUPSUpgradeable holds no state of its own to initialise, so calling it now fails to compile. And __Ownable_init takes the owner’s address in 5.x where the 4.x version took no argument.
Initialise in the same transaction that deploys the proxy. OpenZeppelin supports this by passing the initialiser call as the proxy constructor’s data argument, which removes the window where an uninitialised proxy sits exposed.
In UUPS, _authorizeUpgrade is your entire defence. It is an internal function you override, and the only thing that matters is attaching a real permission check to it, whether that is onlyOwner or a role check of your own.
An empty body with no modifier compiles perfectly and hands your protocol to anyone.
immutable and constant live in bytecode rather than storage, so they are fixed per implementation and cannot carry state across an upgrade. selfdestruct in an implementation can disable the system entirely.
Use the established tooling. The OpenZeppelin Upgrades plugins for Hardhat and Foundry validate storage layout compatibility automatically and will refuse an unsafe upgrade. Hand-rolling this is how layout bugs reach production.
This sequence is what separates a safe upgrade from a hopeful one.
Testing an upgrade against a clean test deployment proves almost nothing. The bugs that matter only appear against state that already exists, which is the step most teams skip. The upgrade path belongs in the same design conversation as how to develop a smart contract in the first place, rather than being bolted on afterwards.
Who can upgrade matters as much as how.
A multisig should hold upgrade rights, never a single key. Signers should be independent people on separate devices, because five keys held by one person is a single key with extra steps.
A timelock then enforces a delay between a proposed upgrade and its execution. This is the control that protects users rather than the team. It gives everyone time to read the new code and exit if they disagree.
Choose the delay honestly. Too short and nobody can react. Too long and you cannot respond to an active exploit. Many protocols run a standard delay for ordinary upgrades plus a separate, faster emergency path with its own narrower permissions.
Transparency completes it:
An upgrade users cannot inspect in advance is indistinguishable from one they should not trust. This governance layer is as much a part of the system as the contracts, and any competent blockchain development company should be designing it alongside them.
The decision is usually straightforward.
If the choice is genuinely close, take UUPS. It is the better-supported default and the gas savings compound across every user interaction.
Most teams discover the hard parts late. The proxy deploys fine, and the trouble surfaces at the first real upgrade, against live state, with funds in the contract.
The work that prevents that is specific. Storage layouts designed to extend safely, initialisers that cannot be hijacked, upgrade authorisation wired to a multisig and timelock, and fork tests that run the real upgrade against real state before anyone signs.
That is the engineering a Smart contract development company like Zyneto handles, together with the application and infrastructure around the contracts. We will also argue for an immutable contract with a pause switch when upgradeability is not worth the surface it adds.
If you are deciding your upgrade path before deployment, that is the cheapest moment to get it right.
Pick upgradeability deliberately rather than by default. Use UUPS unless you have a specific reason not to, treat storage as append-only, disable initialisers on implementations, and gate the upgrade function properly.
Then put the key behind a multisig and a timelock, and test every upgrade against forked mainnet state before it ships.
Not directly. Deployed bytecode is immutable. A proxy pattern achieves the same outcome by keeping state and address in a proxy contract and pointing it at a replaceable implementation.
Transparent proxies hold upgrade logic in the proxy and route calls by caller. UUPS puts that logic in the implementation, making proxies cheaper to deploy and calls cheaper to execute, at the cost of a bricking risk if a version omits the upgrade mechanism.
Solidity assigns storage slots by declaration order. Reordering, removing or retyping a variable between versions shifts everything after it, so the new implementation reads existing data from the wrong slots without reverting.
They add attack surface, including initialiser hijacking, layout corruption and upgrade key compromise. They also let you fix bugs that would otherwise be permanent. Whether that is a net gain depends on the contract and the governance behind it.
Usually yes, by upgrading again to the previous implementation, provided the upgrade mechanism still works and storage was not corrupted. If the new version bricked the upgrade path or scrambled the layout, recovery may be impossible.
Sudhanshi has several years of experience in digital marketing and now works on growth at Zyneto. Her work covers SEO, content strategy, organic lead generation, social media and paid campaigns across Google, LinkedIn, Instagram and Facebook. She has run B2B technology and SaaS campaigns for international audiences, helping brands get found in search and stay found, and works day to day in Google Analytics, Search Console, Ahrefs, SEMrush, Screaming Frog and Canva. She follows AI driven marketing, changing search behaviour, content automation and social growth closely, and turns audience and search data into plans a team can actually run. She writes about SEO, content strategy and the tactics that still earn traffic as search keeps shifting.
Share your details and we will talk soon.
Be the first to access expert strategies, actionable tips, and the trends actually shaping the digital world. No fluff - just practical insights delivered straight to your inbox.
Dive into our blog and stay ahead of the curve with expert perspectives, future-ready trends, and tech tips written for decision-makers and doers alike.