Back to blogUpgradeable smart contracts: proxy patterns, trade-offs

Upgradeable smart contracts: proxy patterns, trade-offs

Web3·June 29, 2026·8 min read·By CodeDecoders Engineering

A deployed smart contract is supposed to be immutable. That is the entire pitch: the code on chain is the code that runs, forever, and nobody can change it on you. Upgradeable smart contracts deliberately break that promise so teams can patch bugs and ship new features after launch. The mechanism is a proxy: a thin contract that holds your data and forwards every call to a separate logic contract you can swap out later.

That swap is the whole point, and it is also where the risk lives. Upgradeability buys you flexibility and trades away the immutability guarantee your users thought they had. It introduces an admin key that can rewrite your contract's behavior, and a class of storage bugs that can silently corrupt every balance you hold. This is a foundational tour of the three proxy patterns in common use (transparent, UUPS, and diamond), what each one trades off, and when you should not make a contract upgradeable at all.

How a proxy actually works

The pattern splits one logical contract into two deployed contracts. The proxy is the address users interact with, and it holds all the state: balances, owners, mappings, everything. The implementation (or logic) contract holds the functions but stores nothing meaningful itself. When a call hits the proxy, its fallback function uses delegatecall to run the implementation's code in the proxy's own storage context. The logic is borrowed; the memory stays with the proxy.

delegatecall is the load-bearing primitive. A normal call runs the target's code against the target's storage. A delegatecall runs the target's code against the caller's storage. So when the proxy delegatecalls into the implementation, the implementation's transfer function reads and writes the proxy's slots. Upgrading is then just pointing the proxy at a new implementation address. The data never moves, the proxy address never changes, and users see a zero-downtime hot swap.

What happens on a single proxy call

Step 1 of 4

User calls the proxy

A wallet sends a transaction to the proxy address, the only address users ever know.

The three proxy patterns

The patterns differ mainly in where the upgrade logic lives and how flexible the swap is. Here is the trade-off at a glance, then the detail.

Upgrade logic lives inThe proxyThe implementationA facet behind the proxy
Per-call gas overheadHigher (admin check every call)LowerSelector lookup per call
Bricking riskLowHigher (forget the upgrade fn, lose upgradeability)Medium (facet management is complex)
24KB size limitConstrainedConstrainedBypassed (split across facets)
Best forSimple, stable contractsMost new projectsLarge modular systems

Transparent proxy

In the transparent pattern, the admin upgrade functions live in the proxy itself. The proxy inspects msg.sender on every call: if the caller is the admin, it runs the proxy's own admin functions; if not, it delegates the call to the implementation. That msg.sender routing exists to prevent a function selector clash, where an admin function and a business function happen to share the same 4-byte selector and the proxy cannot tell them apart. The cost is real: the proxy loads the admin address from storage on every single call, so every interaction pays a little extra gas. It is the oldest and most battle-tested pattern, and a reasonable default for a simple contract that rarely changes.

UUPS proxy

UUPS (Universal Upgradeable Proxy Standard, EIP-1822) moves the upgrade logic into the implementation contract instead. The proxy becomes a minimal forwarder, so normal calls are cheaper, and the selector-clash problem disappears because the Solidity compiler refuses to let one contract define two functions with the same selector. OpenZeppelin now recommends UUPS over transparent for most new projects.

The trade-off is a sharper edge. Because the upgradeTo function lives in the implementation, every future implementation must keep including it. Ship a version that forgets the upgrade function and the proxy is frozen at that logic forever, with no way to upgrade out of the mistake. UUPS gives you cheaper calls in exchange for a footgun you have to remember not to pull.

Diamond pattern

The diamond pattern (EIP-2535) generalizes the idea. Instead of one implementation, the proxy holds a mapping from function selectors to multiple implementation contracts called facets. A call looks up which facet owns its selector and delegatecalls that one. This buys two things: you sidestep the 24KB contract size limit (EIP-170) by spreading logic across facets, and you can upgrade a single function without redeploying everything. The price is complexity. Facet management, shared storage layout across facets, and the tooling around it are significantly harder to reason about and audit, so diamonds make sense for large modular systems and are overkill for most.

Storage collisions: the bug that eats your state

The most dangerous failure mode in upgradeable contracts is the storage collision, and it is dangerous precisely because nothing reverts. Solidity assigns state variables to sequential 32-byte slots starting at slot 0. The proxy and the implementation share that same slot numbering because they share storage. So two rules govern everything.

First, the implementation must never declare a state variable in a slot the proxy uses for its own bookkeeping (like the implementation address). This is solved by ERC-1967, which mandates that the implementation address, admin, and beacon live in specific pseudo-random slots derived from a hash, far away from slot 0 where your business variables sit. Every modern proxy library uses these slots, so you rarely think about it, but it is the reason the two contracts can share storage without stepping on each other.

Second, between versions, you can only append new variables. If V2 inserts a new variable at the top of the list or reorders existing ones, every variable below it shifts to a different slot. Your balances mapping now reads from where owner used to be. No error, no revert, just silently corrupted state across every account. Two safeguards help: declare a uint256[50] __gap in upgradeable base contracts to reserve slots for future fields, and run OpenZeppelin's upgrade plugin, which diffs the old and new layouts and blocks an upgrade that would shift a slot.

There is one more trap that has cost the industry real money: the uninitialized implementation. Upgradeable contracts cannot use constructors (a constructor runs in the implementation's storage, not the proxy's), so they initialize through an initialize() function instead, guarded to run only once. If the implementation contract itself is left uninitialized, an attacker can call its initialize() directly, become its owner, and then trigger a selfdestruct while a delegatecall is in flight, wiping the logic the proxy depends on.

When a contract should not be upgradeable

Upgradeability is not free, and treating it as a default is a mistake. Every upgradeable contract has an admin key, and that key can rewrite the rules: change fees, mint tokens, redirect funds. To your users, "this contract is upgradeable" means "the team can change what this does after I deposit." For a contract that custodies value, that admin key is now the single most attractive target in your system, and how you protect it (a multisig, a timelock, a governance vote) becomes part of your security model whether you planned for it or not.

So weigh it honestly. A contract whose rules must be credibly fixed (a token with a capped supply, a trustless escrow, a vault whose terms users are relying on) is often better off immutable, with new versions shipped as new contracts and users migrating by choice. Upgradeability earns its keep where the logic is genuinely expected to evolve and the team is trusted to govern the key: protocol contracts under active development, systems with regulatory requirements that shift, anything where a bug fix without a full redeploy and migration is worth the added attack surface. The same trade-off logic shows up across on-chain design. We worked through it for asset issuance in real-world asset tokenization architecture, where custody and oracle assumptions decide whether the design holds, and for money movement in shipping stablecoin rails without the pain, where the boring infrastructure is what actually makes it work.

The honest framing: upgradeability is a governance decision wearing an engineering costume. Pick the simplest pattern that fits (transparent for stable contracts, UUPS for most new work, diamond only when size or modularity forces it), lock down the storage layout and the admin key, and be deliberate about which contracts get to change at all. If you are weighing those trade-offs for a system that custodies real value and want a second set of eyes before you commit, get in touch.

Newsletter

New posts, in your inbox

Get an email when we publish a new deep-dive. No spam, unsubscribe anytime.

Start a Project

Let's build something extraordinary together.

Free consultation·Response within 24h·No commitment

info@codedecoders.io