You Deployed It. Now You're Stuck With It.
You're staring at four lines of Solidity that would fix a rounding error quietly bleeding basis points from every user transaction. You know exactly where the bug lives in the fee calculation. You wrote the contract six months ago, shipped it to Ethereum mainnet, collected no proxy, and now those four lines might as well be scratched into the side of a submarine at crush depth: technically reachable, practically unreachable, definitely not getting updated.
That specific nightmare is what pushed the industry toward proxy patterns. And proxy patterns genuinely work, most of the time. But "upgradeable" is not a property you bolt on after the fact. Several specific design choices, made at deployment or even before it, can lock a contract in place permanently regardless of what wrapper surrounds it. This piece is about exactly those choices.
What a Proxy Actually Does (And What It Doesn't)
The core idea is simple. You deploy two contracts: a proxy that holds state and receives all user calls, and an implementation contract that holds the actual code. The proxy uses `delegatecall`, which executes the implementation's code but reads and writes to the proxy's own storage. To upgrade, you point the proxy at a new implementation. Users never change their address; the logic underneath them does.
The two dominant standards are EIP-1967 (used by OpenZeppelin's Transparent Proxy and UUPS patterns) and the older unstructured storage pattern. Both run on the same `delegatecall` principle.
Here is what the proxy does not do. It does not make the implementation contract upgradeable. It does not retroactively fix bad storage layouts. And it cannot override Ethereum's own rules about what bytecode can and cannot do. The proxy is a forwarding address. The constraints live in the code it points to.
The Storage Collision That Quietly Bricks Everything
This is the part most guides bury in a footnote.
When `delegatecall` runs implementation code inside the proxy's context, both contracts share a single storage space indexed by slot number: slot 0, slot 1, slot 2, and so on. If the proxy stores its own admin address in slot 0, and the implementation also expects to store a token balance in slot 0, those two values overwrite each other. No error thrown. No revert. Just wrong data, silently, forever.
OpenZeppelin solved this with EIP-1967 by storing the implementation address at a pseudorandom high slot derived from a hash (`keccak256("eip1967.proxy.implementation") - 1`), making a collision statistically impossible.
The catch: if a developer writes a V2 implementation that adds a new state variable before existing ones in the contract declaration, every subsequent variable shifts down one slot. A `uint256 balance` that lived in slot 2 under V1 now reads from slot 2 under V2, which is where the old `allowance` mapping lived. The upgrade compiles fine. It deploys fine. It silently reads wrong data forever.
There is no on-chain mechanism that detects this. You can deploy V3 to fix the layout, but if V2 already corrupted storage values, those are gone. The upgrade worked technically and failed practically. This is precisely why OpenZeppelin's Upgrades plugin for Hardhat runs a storage layout compatibility check before deployment. Skip that tooling and you are flying blind.
When the Implementation Holds Its Own Keys
Consider a UUPS (Universal Upgradeable Proxy Standard) proxy, where the upgrade logic lives inside the implementation contract rather than the proxy. The proxy is minimal; the implementation contains a function like `upgradeTo(address newImpl)` protected by an access control check.
Now suppose the original developer made the upgrade function ownable, and the owner is a plain externally owned account. That wallet's private key gets lost, or the developer deliberately calls `renounceOwnership()`. The upgrade function still exists in the bytecode. It is reachable. It will simply revert every time anyone calls it, because the owner check fails.
The proxy dutifully points at an implementation that contains upgrade logic it will never execute again. There is no override mechanism in Ethereum. The contract is frozen.
This is not a theoretical failure mode. The Parity multisig wallet incident is the textbook case. A user called an unprotected `initWallet` function on the shared library contract, became its owner, then accidentally self-destructed it. Every wallet contract delegating to that library lost all functionality permanently. A proxy-like pattern was present; the access control was not. Roughly 513,000 ETH became permanently inaccessible. The mechanism was fine. The assumptions around it were not.
`selfdestruct`, Immutable Constructors, and the Nuclear Options
Two more mechanisms no proxy can rescue you from.
If an implementation contract calls `selfdestruct`, its bytecode is wiped from the chain. The proxy still exists, still holds your users' funds, still faithfully forwards calls via `delegatecall` to the old implementation address, which is now empty. Every call returns nothing. You can point the proxy at a new implementation if the upgrade function itself wasn't in the destroyed contract. If it was, you cannot. Done.
EIP-6780, activated in the Dencun upgrade, neutered `selfdestruct` in most contexts, limiting it to contracts destroyed in the same transaction they were created. Still, contracts deployed before that change, and any contract that self-destructs within its creation transaction, remain subject to the old behavior.
Then there are constructor values. Constructors run once at deployment, in the implementation's context if you're careful, and variables set there are stored in the implementation's own storage, not the proxy's. They don't survive an upgrade. This is why upgradeable contracts use `initialize()` functions instead of constructors, and why calling `initialize()` twice is its own class of vulnerability. Pick your poison carefully.
What People Get Wrong About "Upgradeable"
The folk belief worth killing: wrapping a contract in a proxy makes it unconditionally upgradeable. It does not. The proxy is a mechanism, not a guarantee.
What actually determines upgradeability is a checklist of design decisions made before and during deployment. Is the upgrade function access-controlled, and is that access control recoverable? Is the storage layout append-only across versions? Does the implementation avoid `selfdestruct`? Was initialization done correctly, once, via `initialize()` rather than a constructor? Is the proxy pattern the right one for the threat model, given that Transparent, UUPS, and Beacon each carry different failure modes?
Ask yourself honestly: how many teams are checking all five of those boxes under deadline pressure?
Take two developers, both shipping ERC-20 tokens with UUPS proxies. Sarah uses a multisig with four keyholders and OpenZeppelin's Upgrades plugin with storage layout checks enforced in CI. Marcus uses a single EOA he controls personally, skips the plugin, and adds a new variable at the top of his V2 contract. Sarah can upgrade her contract years from now. Marcus technically deployed an "upgradeable" contract that corrupted its own balances on the first upgrade and whose keys now live on a laptop he no longer owns.
Same pattern. Completely different outcomes.
The proxy handles one problem: routing calls to swappable logic. Everything the proxy silently assumes you got right, that is the actual engineering work. Most of the contracts that can't be upgraded today weren't designed to be unupgradeable. They were just designed in a hurry.