Deployed contract code can't be changed. An "upgradeable contract" is really two contracts: a proxy that users interact with and that holds all the storage and funds, and an implementation that holds the logic. Upgrading means pointing the proxy at a new implementation. UUPS and Transparent are two ways of deciding where the upgrade function lives and who is allowed to call it.
The mechanism: delegatecall
The proxy has almost no functions of its own. Its fallback forwards every call to the implementation with delegatecall, which runs the implementation's code in the proxy's context: same storage, same msg.sender, same msg.value, same balance.
user ──call──▶ Proxy (storage, ETH, tokens)
│ delegatecall
▼
Implementation V1 (code only) ──upgrade──▶ Implementation V2
So the implementation's state variables are really slots in the proxy's storage. That has three consequences:
- Constructors don't work. A constructor runs in the implementation's own context at deploy time and never touches the proxy's storage. Upgradeable contracts use an
initializefunction instead, called once through the proxy. - Storage layout is permanent. V2 must interpret the proxy's existing slots the same way V1 did.
- The proxy needs somewhere to store the implementation address that won't collide with the implementation's variables.
Where the implementation address lives
ERC-1967 fixes the slots, derived from a hash so no normal variable will ever land there:
implementation: 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc
admin: 0xb53127684a568b3173ae13b9f8a6016e243e63b6e8ee1178d6a717850b5d6103
That's keccak256("eip1967.proxy.implementation") - 1 and the same for admin. Block explorers read these slots to show "Read as Proxy", and you can too:
cast storage $PROXY 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc --rpc-url $RPC_URL
# or simply
cast implementation $PROXY --rpc-url $RPC_URL
Transparent proxy
The upgrade logic lives in the proxy. To avoid clashes between the proxy's admin functions and the implementation's functions, the proxy checks the caller:
- calls from the admin go to the proxy's own upgrade function and are never forwarded;
- calls from everyone else are always forwarded to the implementation.
In OpenZeppelin Contracts v5, TransparentUpgradeableProxy deploys its own ProxyAdmin contract in its constructor, and that ProxyAdmin is the only address that can upgrade. You control the ProxyAdmin through its owner, ideally a multisig. The admin can't use the application through the proxy, which is why a separate admin contract is used.
Trade-offs: the proxy is bigger and more expensive to deploy, and the admin check adds a little gas to every call. In exchange, the upgrade mechanism can't be removed or broken by a bad implementation.
UUPS proxy
In UUPS (Universal Upgradeable Proxy Standard, ERC-1822), the proxy is minimal and the upgrade function lives in the implementation. You inherit UUPSUpgradeable and decide who can upgrade by overriding _authorizeUpgrade:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.22;
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";
contract BoxV1 is Initializable, OwnableUpgradeable, UUPSUpgradeable {
uint256 public value;
/// @custom:oz-upgrades-unsafe-allow constructor
constructor() {
_disableInitializers(); // nobody can initialize the implementation itself
}
function initialize(address initialOwner) public initializer {
__Ownable_init(initialOwner);
}
function setValue(uint256 newValue) external onlyOwner {
value = newValue;
}
function _authorizeUpgrade(address) internal override onlyOwner {}
}
Upgrades go through the proxy as a normal call: upgradeToAndCall(newImplementation, data), which runs _authorizeUpgrade first. OpenZeppelin's version also checks that the new implementation is itself UUPS-compatible before switching.
Trade-offs: cheaper deployment and calls, and you can eventually remove upgradeability by deploying an implementation without the upgrade function. The flip side is that you can do that by accident. If a new version forgets to inherit UUPSUpgradeable, or _authorizeUpgrade has a bug, the proxy may be stuck forever or upgradeable by anyone.
Pitfalls for both patterns
Initialize atomically. Deploy the proxy and call initialize in the same transaction (both OpenZeppelin proxy constructors accept initialization data). Otherwise someone can front-run your initialize and become the owner.
Disable initializers on the implementation. The implementation is a live contract too. The _disableInitializers() constructor above stops anyone from initializing it directly and taking ownership of it.
Never reorder storage. In V2, keep every existing state variable in the same order with the same type. Only add new variables after the existing ones. Don't change the inheritance order. Violating this makes V2 read V1's data from the wrong slots, silently corrupting balances or ownership. OpenZeppelin v5 upgradeable contracts keep their own state in namespaced storage (ERC-7201), which removes most of the need for the older __gap arrays, but your own variables still follow the rules.
Be careful with immutable variables and constructor logic. They're baked into the implementation's bytecode, not stored in the proxy, so they change with every upgrade and can't be set per proxy.
Validate before every upgrade. Use the OpenZeppelin plugins, which compare storage layouts between versions and refuse unsafe changes:
// Hardhat, with @openzeppelin/hardhat-upgrades
import { ethers, upgrades } from "hardhat";
const [owner] = await ethers.getSigners();
const Box = await ethers.getContractFactory("BoxV1");
const box = await upgrades.deployProxy(Box, [owner.address], { kind: "uups" });
const BoxV2 = await ethers.getContractFactory("BoxV2");
await upgrades.upgradeProxy(await box.getAddress(), BoxV2);
Foundry users get the same checks from the openzeppelin-foundry-upgrades library.
Which to choose
| Transparent | UUPS | |
|---|---|---|
| Upgrade logic lives in | Proxy (plus ProxyAdmin) | Implementation |
| Deploy cost | Higher | Lower |
| Per-call overhead | Admin check on every call | None beyond delegatecall |
| Risk of bricking by a bad upgrade | Low | Real, if V2 drops or breaks _authorizeUpgrade |
| Can remove upgradeability later | Only by renouncing admin ownership | Yes, by upgrading to a non-UUPS implementation |
For new projects, UUPS is the common default and OpenZeppelin's recommendation, as long as you validate each upgrade. Pick Transparent if you want the upgrade path to be independent of implementation code.
Checklist
- Replace constructors with
initializerfunctions, and call_disableInitializers()in the implementation's constructor. - Deploy and initialize the proxy in one transaction.
- Append-only storage; never reorder, remove or retype variables.
- For UUPS, protect
_authorizeUpgradeand keep inheritingUUPSUpgradeablein every version. - Run the OpenZeppelin upgrade validation before each upgrade.
- Put upgrade rights behind a multisig and, ideally, a timelock.
Get the weekly commit
New blockchain deep dives every week.