Smart contracts are the backbone of decentralized applications (dApps) on blockchains like Ethereum. However, their immutability—while ensuring trust and security—presents a challenge when updates are needed. This is where upgradable smart contracts come into play.
In this comprehensive guide, you’ll learn the core principles behind upgradable smart contracts, including why they’re necessary, how to implement them using modern proxy patterns, and what pitfalls to avoid during deployment and upgrades. We’ll focus on Ethereum and EVM-compatible blockchains, using Solidity and Hardhat for hands-on implementation.
Whether you're fixing bugs, improving functionality, or adapting to market changes, upgradable contracts allow seamless evolution without disrupting user experience.
👉 Discover how blockchain developers deploy scalable dApps with advanced contract architecture.
Why Upgrade Smart Contracts?
Blockchain data is immutable by design—once deployed, a smart contract cannot be altered. But real-world applications require maintenance and improvement. Here’s why upgrades matter:
- Bug fixes: Patch vulnerabilities post-deployment.
- Feature enhancements: Add new capabilities without migration.
- Gas optimization: Improve efficiency and reduce transaction costs.
- Regulatory or market adaptation: Respond to external changes.
- User retention: Avoid forcing users to migrate to new contract addresses.
Since we can’t change existing code, the solution lies in design patterns that decouple contract logic from state. This enables replacing the "logic" while preserving data and maintaining a consistent user-facing address.
Understanding Upgradable Contract Architecture
Upgradable smart contracts rely on the proxy pattern, a design that separates execution logic from storage. The core components are:
- Proxy Contract – Holds the application’s state and serves as the permanent entry point.
- Logic (Implementation) Contract – Contains executable functions; can be swapped out.
The proxy forwards calls to the logic contract using delegatecall, which executes the logic in the proxy’s context—meaning state changes affect the proxy’s storage.
This setup allows developers to deploy a new logic contract and redirect the proxy to it—effectively upgrading the system without changing its address or losing data.
Common Proxy Patterns
There are several approaches to implementing upgradable contracts. Each has trade-offs in security, gas cost, and complexity.
1. Simple Proxy Pattern
In this basic model:
- The proxy uses
fallback()to forward all calls viadelegatecall. - Logic contract runs in proxy’s execution context.
- State is stored in the proxy.
Limitation: No protection against function selector clashes—when two functions share the same 4-byte signature, leading to unexpected behavior or exploits.
2. Transparent Proxy Pattern
Popularized by OpenZeppelin, this pattern adds access control:
- If the caller is the admin, admin functions (like
upgradeTo) are executed directly. - Otherwise, all calls are delegated to the logic contract.
This prevents accidental conflicts but introduces higher gas costs due to additional checks in fallback.
A key enhancement: use of a ProxyAdmin contract to manage upgrades securely across multiple proxies.
3. UUPS (Universal Upgradable Proxy Standard)
Defined in EIP-1822, UUPS moves upgrade logic into the logic contract itself:
- The logic contract inherits upgrade functionality (e.g., via OpenZeppelin’s
UUPSUpgradeable). - Reduces gas cost on deployment.
- Enables detection of selector clashes at compile time.
However, if a future version doesn’t inherit the upgradeable base, the contract becomes permanently un-upgradable—a trade-off for flexibility.
👉 Learn how top Web3 projects streamline contract deployment with secure upgrade paths.
Hands-On: Deploying an Upgradable Contract with Hardhat
Let’s walk through deploying and upgrading a price-tracking contract using Chainlink oracles and OpenZeppelin’s upgrade plugin.
Prerequisites
Ensure you have:
- Basic knowledge of Solidity and Ethereum.
- Node.js and Yarn installed.
- A wallet with Goerli ETH (use a faucet).
- Alchemy or Infura RPC URL and private key set as environment variables.
Step 1: Project Setup
Initialize your project and install dependencies:
yarn add -D hardhat @openzeppelin/hardhat-upgrades @nomiclabs/hardhat-ethers ethers
yarn add @chainlink/contracts @openzeppelin/contracts-upgradeableCreate hardhat.config.js:
require("@nomiclabs/hardhat-ethers");
require("@openzeppelin/hardhat-upgrades");
const GOERLI_RPC_URL = process.env.GOERLI_RPC_URL;
const PRIVATE_KEY = process.env.PRIVATE_KEY;
const ETHERSCAN_KEY = process.env.ETHERSCAN_API_KEY;
module.exports = {
solidity: "0.8.17",
defaultNetwork: "hardhat",
networks: {
goerli: {
url: GOERLI_RPC_URL,
accounts: PRIVATE_KEY ? [PRIVATE_KEY] : [],
chainId: 5,
},
},
etherscan: {
apiKey: ETHERSCAN_KEY,
},
};Step 2: Write the Initial Contract (V1)
Create contracts/PriceFeedTracker.sol:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "@chainlink/contracts/src/v0.8/interfaces/AggregatorV3Interface.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
contract PriceFeedTracker is Initializable {
address private admin;
function initialize(address _admin) public initializer {
admin = _admin;
}
function getAdmin() public view returns (address) {
return admin;
}
function retrievePrice() public view returns (int) {
AggregatorV3Interface aggregator = AggregatorV3Interface(
0xD4a33860578De61DBAbDc8BFdb98FD742fA7028e // Goerli ETH/USD feed
);
(, int price, , , ) = aggregator.latestRoundData();
return price;
}
}Note the use of Initializable instead of a constructor—required for upgradable contracts.
Step 3: Deploy Script
Create scripts/deploy_upgradeable_pricefeedtracker.js:
const { ethers, upgrades } = require("hardhat");
async function main() {
const PriceFeedTracker = await ethers.getContractFactory("PriceFeedTracker");
const [deployer] = await ethers.getSigners();
console.log("Deploying PriceFeedTracker...");
const proxy = await upgrades.deployProxy(PriceFeedTracker, [deployer.address], { initializer: "initialize" });
await proxy.deployed();
console.log("PriceFeedTracker deployed to:", proxy.address);
}
main().catch(console.error);Deploy with:
yarn hardhat run --network goerli scripts/deploy_upgradeable_pricefeedtracker.jsThis deploys:
- Logic V1 contract
- Proxy contract (user-facing)
- ProxyAdmin (manages upgrades)
Record the proxy address—it remains constant across upgrades.
Step 4: Upgrade to V2
Update functionality to support dynamic price feeds.
Create PriceFeedTrackerV2.sol:
contract PriceFeedTrackerV2 is Initializable {
address private admin;
int public price;
event PriceRetrievedFrom(address feed, int price);
function initialize(address _admin) public initializer {
admin = _admin;
}
function getAdmin() public view returns (address) {
return admin;
}
function retrievePrice(address feed) public returns (int) {
require(feed != address(0), "Invalid feed address");
AggregatorV3Interface aggregator = AggregatorV3Interface(feed);
(, int _price, , , ) = aggregator.latestRoundData();
price = _price;
emit PriceRetrievedFrom(feed, _price);
return price;
}
}Ensure storage layout remains unchanged—new variables must be appended, not inserted.
Upgrade script (scripts/upgrade_pricefeedtracker.js):
const { upgrades } = require("hardhat");
async function main() {
const proxyAddress = "YOUR_PROXY_ADDRESS";
const PriceFeedTrackerV2 = await ethers.getContractFactory("PriceFeedTrackerV2");
console.log("Upgrading to V2...");
await upgrades.upgradeProxy(proxyAddress, PriceFeedTrackerV2);
console.log("Upgrade complete.");
}
main().catch(console.error);Run:
yarn hardhat run --network goerli scripts/upgrade_pricefeedtracker.jsThe proxy address stays the same—users continue interacting seamlessly.
Best Practices & Pitfalls
✅ Do:
- Use OpenZeppelin’s upgrade plugins for safety.
- Maintain consistent storage layout across versions.
- Test upgrades locally before mainnet deployment.
- Use UUPS for gas-efficient deployments.
❌ Don’t:
- Modify order of state variables.
- Forget to mark initializer functions with
initializer. - Expose upgrade functions to unauthorized callers.
Frequently Asked Questions
Q: Can any smart contract be made upgradable?
A: Technically yes, but it adds complexity. Only use upgradability when necessary—critical for dApps needing long-term maintenance.
Q: Is upgradability secure?
A: When implemented correctly (e.g., with access control), yes. However, centralizes trust in admins. Consider timelocks or DAO governance for production systems.
Q: What happens if I change storage layout during upgrade?
A: It causes storage clashes, corrupting data. Always append new variables at the end.
Q: How do I verify an upgradable contract on Etherscan?
A: Verify both logic and proxy separately. Use OpenZeppelin’s CLI tools to assist with verification of proxy contracts.
Q: Can I disable upgradability permanently?
A: Yes—with UUPS, simply deploy a version that doesn’t inherit upgrade logic. With transparent proxies, renounce ownership of ProxyAdmin.
Q: Are there alternatives to proxy patterns?
A: Yes—some projects use diamond patterns (EIP-2535) for modular logic, but they’re more complex. Proxies remain the standard for most use cases.
Conclusion
Upgradable smart contracts empower developers to build resilient, evolving dApps while preserving user trust and continuity. By leveraging proxy patterns like Transparent or UUPS—and tools like Hardhat and OpenZeppelin—you can deploy secure, maintainable systems on Ethereum and EVM chains.
Always prioritize security audits, test thoroughly in staging environments, and design with long-term governance in mind.
👉 Explore how leading DeFi platforms manage smart contract lifecycle securely and efficiently.