The Ultimate Guide to Upgradeable Smart Contracts in 2025

Written By ApexWeb3

Code may be considered law, but changes are necessary when the law needs to adapt. Here’s how to build flexible smart contracts that can evolve with your project.

TL;DR

  • Upgradeable smart contracts address the challenges of immutability in protocols that need to evolve.
  • Common implementation methods include using proxy patterns, diamond patterns, and migration techniques.
  • OpenZeppelin’s Upgrades Plugins provide battle-tested upgrade mechanisms
  • Security considerations remain critical—especially around admin access and storage collision
  • Current best practices favor UUPS proxies due to gas efficiency and security benefits

Why Upgradeable Smart Contracts Matter in 2025

In the blockchain world, immutability is a feature, not a bug—until you need to patch a critical vulnerability or adapt your protocol to changing market conditions. Upgradeable smart contracts have evolved from a controversial workaround to an essential architecture pattern for any serious protocol.

With over $2 billion lost to smart contract exploits since 2020, the ability to patch vulnerabilities post-deployment isn’t just convenient—it’s existential. However, upgradeable smart contracts bring along unique risks and increased complexity.

As a senior blockchain engineer who has deployed multiple upgradeable systems, I’ll walk you through how to implement these patterns securely in 2025, using the latest tools and best practices from OpenZeppelin, Consensys, and the broader Ethereum ecosystem.

Use Cases: When You Need Upgradeable Smart Contracts

Not every contract needs to be upgradeable. The added complexity creates potential security risks, so you should only use this pattern when necessary:

  • Protocols that are designed to run for extended periods, such as DAOs, DeFi lending platforms, or those requiring future changes.
  • Complex business logic – Systems where bugs might appear despite thorough testing
  • Early-stage products – Projects likely to pivot or evolve based on market feedback
  • Governance-controlled systems – Where token holders should be able to vote on upgrades

When would you avoid upgradeable contracts? For simple, audited functionality like basic ERC20 tokens, where immutability is actually a security feature.

The Upgrade Stack: Tools & Frameworks

Let’s look at what we’ll be using:

  • OpenZeppelin Upgrades Plugins: Hardhat and Truffle plugins that make deploying and upgrading proxies safer
  • OpenZeppelin Contracts: Library with upgradeable variants of standard contracts
  • Hardhat: Development environment for compiling, deploying, and testing
  • Etherscan: For verifying our proxy contracts

Install the core dependencies:

PowerShell
# Create a new hardhat project
mkdir upgradeable-contract && cd upgradeable-contract
npm init -y
npm install --save-dev hardhat @openzeppelin/hardhat-upgrades @openzeppelin/contracts-upgradeable
npx hardhat init

Upgradeable Smart Contracts: Implementation Approaches

The Proxy Pattern

The most common approach to upgradeable contracts utilizes proxy patterns. A proxy is a simple contract that:

  1. Stores the address of an implementation contract
  2. Forwards all calls to that implementation via delegatecall
  3. Maintains its own storage that the implementation logic can access
Proxy Pattern Diagram
Proxy Pattern Diagram

Here are the main proxy patterns in use today:

  1. Transparent Proxy Pattern – The original OpenZeppelin pattern
  2. UUPS (Universal Upgradeable Proxy Standard) – The modern, gas-efficient approach
  3. Diamond Pattern (EIP-2535) – Multi-facet proxy for complex systems

Let’s implement the UUPS pattern, which is generally considered the best approach in 2025 due to its gas efficiency and security advantages.

Code Implementation: UUPS Upgradeable Contract

Step 1: Create your implementation contract

Solidity
// SPDX-License-Identifier: MIT 
pragma solidity ^0.8.20;

import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol";

contract MyTokenV1 is Initializable, UUPSUpgradeable, OwnableUpgradeable {
    // Note: no constructors in upgradeable contracts
    string public name;
    string public symbol;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;
    
    /// @custom:oz-upgrades-unsafe-allow constructor
    constructor() {
        _disableInitializers();
    }
    
    function initialize(string memory _name, string memory _symbol) 
        public initializer {
        __Ownable_init(msg.sender); // Initialize the owner
        __UUPSUpgradeable_init();   // Initialize UUPS
        
        name = _name;
        symbol = _symbol;
        // No initial supply in V1
    }
    
    function transfer(address to, uint256 amount) public returns (bool) {
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += amount;
        
        return true;
    }
    
    // Critical UUPS function - only owner can upgrade
    function _authorizeUpgrade(address newImplementation) 
        internal override onlyOwner {}
}

Step 2: Deploy the implementation and proxy

Create a deployment script:

JavaScript
// scripts/deploy.js
const { ethers, upgrades } = require("hardhat");

async function main() {
  const MyToken = await ethers.getContractFactory("MyTokenV1");
  
  console.log("Deploying proxy and implementation...");
  const proxy = await upgrades.deployProxy(
    MyToken, 
    ["MyToken", "MTK"], // Parameters for initialize()
    { kind: 'uups' }
  );
  
  await proxy.waitForDeployment();
  const proxyAddress = await proxy.getAddress();
  console.log("Proxy deployed to:", proxyAddress);
  
  // Get implementation address
  const implAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
  console.log("Implementation deployed to:", implAddress);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

Run the deployment:

PowerShell
npx hardhat run scripts/deploy.js --network goerli

Step 3: Create an upgraded version

Solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "./MyTokenV1.sol";

contract MyTokenV2 is MyTokenV1 {
    // New state variables must be added at the end
    uint8 public decimals;
    
    // New function for V2
    function mint(address to, uint256 amount) public onlyOwner {
        balanceOf[to] += amount;
        totalSupply += amount;
    }
    
    // Update an existing function
    function transfer(address to, uint256 amount) public override returns (bool) {
        // Add a transaction fee of 1%
        uint256 fee = amount / 100;
        uint256 netAmount = amount - fee;
        
        require(balanceOf[msg.sender] >= amount, "Insufficient balance");
        
        balanceOf[msg.sender] -= amount;
        balanceOf[to] += netAmount;
        balanceOf[owner()] += fee; // Fee goes to owner
        
        return true;
    }
    
    // Initialize new state in the V2 upgrade
    function initializeV2(uint8 _decimals) public reinitializer(2) {
        decimals = _decimals;
    }
}

Step 4: Deploy the upgrade

JavaScript
// scripts/upgrade.js
const { ethers, upgrades } = require("hardhat");

async function main() {
  const proxyAddress = "YOUR_PROXY_ADDRESS";  // From previous deployment
  
  const MyTokenV2 = await ethers.getContractFactory("MyTokenV2");
  console.log("Upgrading proxy...");
  
  await upgrades.upgradeProxy(proxyAddress, MyTokenV2);
  console.log("Proxy upgraded");
  
  // Initialize V2-specific state
  const proxy = await ethers.getContractAt("MyTokenV2", proxyAddress);
  const tx = await proxy.initializeV2(18);
  await tx.wait();
  console.log("V2 initialized with decimals = 18");
  
  // Get the new implementation address
  const implAddress = await upgrades.erc1967.getImplementationAddress(proxyAddress);
  console.log("New implementation deployed to:", implAddress);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
    console.error(error);
    process.exit(1);
  });

Run the upgrade:

PowerShell
npx hardhat run scripts/upgrade.js --network goerli

Tips, Gotchas, and Gas Traps

Storage Layout Pitfalls

The most dangerous aspect of upgradeable contracts is storage layout. When upgrading, you must ensure new versions maintain the same storage layout to prevent overwriting existing data.

Solidity
// ❌ DON'T DO THIS IN V2
contract MyTokenV2Bad is MyTokenV1 {
    // Inserting a variable here will shift all other storage slots!
    uint256 public newFeatureValue; // BAD: breaks storage layout
    
    // Original variables now point to wrong storage slots
}

// ✅ DO THIS INSTEAD
contract MyTokenV2Good is MyTokenV1 {
    // Add new variables at the end
    uint256 public newFeatureValue; // GOOD: appends to storage
}

Uninitialized Implementation Vulnerability

OpenZeppelin’s plugins protect against this, but if you’re implementing manually, remember that the implementation contract itself must be protected against direct calls:

Solidity
// Implementation must have its initialize() function disabled
constructor() {
    // This prevents calling initialize() on the implementation contract
    _disableInitializers();
}

Contract Size Limits

Proxies help bypass the 24KB contract size limit by separating logic from storage. However, each implementation is still subject to this limit. If your contract approaches this limit:

Solidity
// Split functionality into multiple contracts
contract TokenLogic is UUPSUpgradeable { /* core logic */ }
contract StakingLogic is UUPSUpgradeable { /* staking logic */ } 

// Or use the Diamond Pattern for multi-facet proxies

Access Control Vulnerabilities

The AccessControlUnauthorizedAccount error from OpenZeppelin indicates a permission issue. Always carefully manage who can trigger upgrades:

Solidity
// Safer upgrade authorization with timelock
function _authorizeUpgrade(address) internal override onlyOwner {
    require(block.timestamp >= proposedUpgradeTime + 2 days, "Timelock not expired");
    // Reset for next upgrade
    proposedUpgradeTime = 0;
}

function proposeUpgrade() external onlyOwner {
    proposedUpgradeTime = block.timestamp;
}

This 48-hour delay gives users time to exit if they disagree with an upgrade.

Gas Efficiency: UUPS vs Transparent

UUPS proxies are more cost-effective in terms of gas usage since the upgrade logic is stored in the implementation rather than the proxy. This saves ~2,000 gas per transaction compared to transparent proxies.

// this trick saved me 30% gas in production

Solidity
// Use UUPS instead of Transparent proxies when possible
import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";

Real-World Example: Aave Protocol

Aave uses upgradeable contracts to manage $5B+ in assets. Their v3 implementation uses:

  1. A governance timelock for upgrades
  2. UUPS proxies for implementation logic
  3. Multi-layer architecture with clear delegated responsibilities

You can examine their proxy patterns on GitHub.

The Future of Upgradeable Contracts

The industry is moving toward more secure, transparent upgrade patterns:

  • Time-delayed upgrades with social consensus verification
  • Opt-in upgrades where users can choose to migrate
  • Partial upgradeability where critical functions remain immutable
  • DAO governance for decentralized upgrade decisions

As we move into late 2025, expect to see more standardized approaches to upgrades as part of EIP-7281, advancing the UUPS pattern.

Conclusion: To Upgrade or Not to Upgrade?

Upgradeable smart contracts provide crucial flexibility, especially for complex protocols. However, they come with security tradeoffs that must be managed carefully:

  1. Always use established libraries like OpenZeppelin’s Upgrades Plugins
  2. Implement timelocks and multi-sig requirements for upgrade functions
  3. Be extremely careful with storage layout when designing for upgradeability
  4. Document your upgrade procedures thoroughly, including emergency protocols
  5. Consider which parts of your system truly need upgradeability

For simple, well-tested contracts like standard tokens, the additional security of immutability may outweigh the benefits of upgradeability.

The best upgradeable contract is one that never needs to be upgraded—but the option to fix critical bugs can be the difference between a minor incident and a catastrophic exploit.

Join the Discussion

Building something with upgradeable contracts? Have questions about implementation details? Join our Discord community to discuss with other developers tackling similar challenges.

Code safe, degen responsibly.

Featured Articles