Upgradeable Smart Contracts: Proxy Patterns, Security Risks, and Deployment Best Practices

11 min read
15 Oct 2026
Upgradeable Smart Contracts: Proxy Patterns, Security Risks, and Deployment Best Practices

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.

What Are Upgradeable Smart Contracts?

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.

How Do Proxy Contracts Work?

The whole pattern rests on one EVM instruction: delegatecall.

► What delegatecall Actually Does

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.

► The Storage Collision Problem

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.

► How ERC-1967 Solves It

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.

UUPS vs Transparent vs Beacon Proxies

Three patterns dominate, and they differ mainly in where the upgrade logic lives.

► Transparent Proxy

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.

► UUPS Proxy

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.

► Beacon Proxy

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.

When Should a Smart Contract Be Upgradeable?

Upgradeability is a trade-off, not an improvement. Reach for it deliberately.

► Good reasons to make a contract upgradeable:

  • The protocol is complex and genuinely likely to need fixes.
  • Regulatory or business rules may change during its life.
  • Real user funds are at stake and an unpatchable bug would be severe.
  • The product is early and the design will keep moving.

► Good reasons not to:

  • The contract is simple and its logic is fully settled.
  • Credible neutrality is the product, and users must know the rules cannot change.
  • No trustworthy governance exists to hold the upgrade key.
  • The added complexity would exceed the risk it mitigates.

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.

Most Important Upgradeability Security Risks

These are the failures that recur. Each has caused real losses.

1. Uninitialised Implementation Contracts

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.

2. Storage Layout Collisions

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.

3. Unprotected Upgrade Functions

An upgrade function without proper access control lets anyone replace your logic. In UUPS that means _authorizeUpgrade must carry a real permission check.

4. Function Selector Clashes

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.

5. Constructors That Never Run

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.

6. Centralisation of the Upgrade Key

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.

Planning an upgrade path before you deploy?

Design Rules: Storage Layout, Initialisation and Access Control

Four rules prevent most of what goes wrong.

► Treat Storage as Append-Only

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.

► Use Initialisers Correctly

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.

► Gate the Upgrade Path

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.

► Know What Does Not Work

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.

Step-by-Step Deployment and Upgrade Testing Workflow

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.

1. First Deployment

  • Write the implementation with initialisers rather than a constructor.
  • Deploy the proxy and run the initialiser in the same transaction.
  • Transfer upgrade rights to a multisig immediately, not later.
  • Verify both contracts on the block explorer so users can read the code.

2. Preparing an Upgrade

  • Write the new implementation as a strict extension of the old layout.
  • Run the plugin’s layout validation and treat any warning as blocking.
  • If the new version adds state, add a function guarded by OpenZeppelin’s reinitializer modifier at a higher version number.

3. Testing Before Mainnet

This sequence is what separates a safe upgrade from a hopeful one.

  • Fork mainnet locally and run the upgrade against real state, not a fresh deployment.
  • Assert that balances, ownership and critical mappings read identically before and after.
  • Run the previous version’s full test suite against the new implementation.
  • Dry-run the exact upgrade transaction the multisig will sign.

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.

Governance: Multisig, Timelocks and Transparency

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:

  • Publish the new implementation address and verified source before execution.
  • Explain what changed and why, in plain language.
  • Document who holds signing keys and what the thresholds are.

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.

How to Choose the Right Proxy Pattern

The decision is usually straightforward.

  1. Choose UUPS for most new single-contract deployments. It is cheaper per proxy, it is where OpenZeppelin’s recommendation now points, and the bricking risk is manageable with the standard tooling.
  2. Choose Transparent when you are extending an existing transparent deployment, or when your team strongly prefers that upgrade authority cannot be removed from the proxy by a bad implementation.
  3. Choose Beacon when you deploy many instances of the same contract and need to upgrade them together. Think per-user vaults, per-asset escrow contracts, or a factory that mints a fresh contract for every deal. Upgrading those one by one stops being realistic past a few hundred.
  4. Choose immutable when the logic is simple and settled, or when guaranteed unchangeability is the product.

If the choice is genuinely close, take UUPS. It is the better-supported default and the gas savings compound across every user interaction.

Not sure your proxy setup is safe to upgrade?

How Can Zyneto Help With Upgradeable Smart Contracts?

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.

Conclusion

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.

FAQs

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 Bakre

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.

Let's make the next big thing together!

Share your details and we will talk soon.

Phone

We respond to all inquiries within 1 hour.

WhatsApp
Email
Book a Meeting