← Back to Blog

OpenZeppelin v4 vs v5 Migration: 7 API Changes That Break Your Contracts

2026-04-30 openzeppelin migration v5 solidity ownable ecdsa messagehashutils counters reentrancyguard audit 2026

OpenZeppelin Contracts v5 (released mid-2024, mainstream by 2025–2026) is not a drop-in upgrade from v4. The maintainers used the major-version bump to clean up years of accumulated API drift: removing helpers that should never have been there, renaming draft- files that were no longer drafts, splitting overloaded libraries, and standardizing on custom errors throughout.

For a developer migrating an existing protocol — or for a security engineer auditing one that recently migrated — knowing exactly which APIs changed is the difference between a patch that compiles and a patch that does not. This post walks through the seven changes that most often break contracts in the wild.


The Compatibility Surface

Roughly speaking, v4→v5 falls into three buckets:

  1. API renames / moves — same functionality, different import path or library. Easy to catch (compile error), easy to fix.
  2. Constructor signature changes — same intent, different parameters. Often silently miscompiles or shifts behavior.
  3. Removed helpers — features the maintainers decided were anti-patterns. Requires a small refactor.

The seven changes below cover all three buckets, in rough order of how often they hit production codebases.


1. Ownable Now Requires an Explicit Initial Owner

v4 — implicit owner is msg.sender (the deployer):

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyVault is Ownable {
    constructor() Ownable() {
        // implicit: _owner = msg.sender
    }
}

v5 — owner must be passed explicitly:

import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";

contract MyVault is Ownable {
    constructor(address initialOwner) Ownable(initialOwner) {}
}

Why it matters: v5 forces you to think about who the owner is at deployment. Many factory-deployed contracts in v4 silently inherited owner from the factory contract — usually not what was wanted. v5 surfaces that decision.

Migration trap: passing msg.sender as the initial owner restores v4 behavior, but if your factory creates many child contracts on behalf of users, you almost certainly want to pass owner_ (the actual user) instead.


2. MessageHashUtils Replaces ECDSA.toEthSignedMessageHash

v4toEthSignedMessageHash lived on the ECDSA library:

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";

bytes32 ethHash = ECDSA.toEthSignedMessageHash(rawHash);
address signer = ECDSA.recover(ethHash, signature);

v5 — split into a dedicated MessageHashUtils:

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {MessageHashUtils} from "@openzeppelin/contracts/utils/cryptography/MessageHashUtils.sol";

bytes32 ethHash = MessageHashUtils.toEthSignedMessageHash(rawHash);
address signer = ECDSA.recover(ethHash, signature);

Why it matters: cleaner separation between recovery (ECDSA) and message-formatting (MessageHashUtils). For audit purposes, the new layout makes signature-verification flows easier to trace.

Common compile error: Error: Member "toEthSignedMessageHash" not found. The fix is one new import.


3. Counters.sol Has Been Removed

v4Counters.Counter was the conventional way to track sequential token IDs:

import {Counters} from "@openzeppelin/contracts/utils/Counters.sol";

contract NFT {
    using Counters for Counters.Counter;
    Counters.Counter private _tokenIds;

    function mint() external returns (uint256) {
        _tokenIds.increment();
        return _tokenIds.current();
    }
}

v5Counters has been removed entirely. Use a plain uint256:

contract NFT {
    uint256 private _nextTokenId;

    function mint() external returns (uint256) {
        return _nextTokenId++;
    }
}

Why it matters: Counters added a wrapper struct around a plain integer that gave nothing in return — Solidity 0.8.x already protects against overflow, so the original use case (safe increment) was obsolete. The maintainers removed it rather than continue ship dead code.

Migration trap: simply replacing the type is not always enough. If the v4 contract used _tokenIds.current() before _tokenIds.increment() to read the current ID, the v5 equivalent is the value _nextTokenId holds, not _nextTokenId++. Watch the off-by-one.


4. ReentrancyGuard Moved from security/ to utils/

v4:

import {ReentrancyGuard} from "@openzeppelin/contracts/security/ReentrancyGuard.sol";

v5:

import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

Why it matters: this is the most common single-line break in real migrations. The behavior of nonReentrant itself is unchanged.

Bonus: v5 also ships ReentrancyGuardTransient (under utils/), which uses transient storage (EIP-1153) for substantially cheaper gas. Worth considering if your contract targets a chain that supports transient storage.


5. draft-EIP712.sol and draft-IERC20Permit.sol Were Renamed

v4 — files prefixed draft- because the underlying EIP was unfinalized at the time:

import {EIP712} from "@openzeppelin/contracts/utils/cryptography/draft-EIP712.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/draft-IERC20Permit.sol";

v5 — the EIPs are finalized, so the prefix is gone:

import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";
import {IERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/IERC20Permit.sol";

Why it matters: pure cosmetic, but a frequent source of CI failures during upgrade PRs.

Migration tip: a one-line find/sed covers it:

git ls-files '*.sol' | xargs sed -i \
  -e 's|cryptography/draft-EIP712|cryptography/EIP712|g' \
  -e 's|extensions/draft-IERC20Permit|extensions/IERC20Permit|g'

6. Custom Errors Replace String require Throughout

v4 — many libraries used require with string messages:

require(amount > 0, "ERC20: transfer amount must be greater than zero");

v5 — same checks are now custom errors that revert with structured data:

if (amount == 0) {
    revert ERC20InvalidAmount(amount);
}

Why it matters: smaller bytecode (custom errors are ~50 bytes; string reverts are kilobytes), and ABIs that include error definitions so off-chain tooling can decode reverts cleanly.

Migration trap: integration tests that asserted on vm.expectRevert("ERC20: transfer amount...") will silently start passing or failing. Update those tests to vm.expectRevert(IERC20Errors.ERC20InvalidAmount.selector) or similar.

Audit trap: any front-end that used error.message to display revert reasons needs updating. The string is gone; you need the error selector and the typed args.


7. AccessControlDefaultAdminRules Replaces Manual DEFAULT_ADMIN_ROLE Wiring

v4 — typical setup:

import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol";

contract Vault is AccessControl {
    constructor(address admin) {
        _grantRole(DEFAULT_ADMIN_ROLE, admin);
    }
}

v5 — recommended replacement adds a built-in 5-day delay before admin transfers can take effect:

import {AccessControlDefaultAdminRules} from
    "@openzeppelin/contracts/access/extensions/AccessControlDefaultAdminRules.sol";

contract Vault is AccessControlDefaultAdminRules {
    constructor(address admin)
        AccessControlDefaultAdminRules(5 days, admin)
    {}
}

Why it matters: many post-mortem reports of admin-key compromise note that the attacker rotated DEFAULT_ADMIN_ROLE instantly. v5's AccessControlDefaultAdminRules requires a beginDefaultAdminTransfer followed by acceptDefaultAdminTransfer after the configured delay, giving the team a window to detect and revoke a malicious transfer.

Audit recommendation: prefer the DefaultAdminRules variant for any production contract. If the project deliberately uses the older AccessControl for admin, ask why.


How ContractScan Handles This

Beyond the migration guide above, the practical implication for a security scanner is that patch suggestions must match the user's actual OZ version. A scanner that emits MessageHashUtils to a v4 codebase, or Ownable() {} to a v5 codebase, has produced a patch that will not compile.

ContractScan's pipeline now does two things to address this:

  1. Detects the user's stack — pragma plus import paths plus a fallback heuristic on the Solidity minor version — and pins the AI prompt to that exact version.
  2. Verifies every patch — before showing a suggested fix, the patch is wrapped in a minimal compilation unit using the user's pragma and imports, and run through solc against the vendored dependencies. Patches that compile get a green check; patches that do not get a warning with the exact compiler error.

For more detail on the verification mechanism, see Verified Patch Generation: Why AI-Suggested Solidity Fixes Fail to Compile.


Migration Checklist

For teams about to upgrade a v4 codebase to v5:


Related: Verified Patch Generation: Why AI-Suggested Solidity Fixes Fail to Compile — the companion piece on why version-aware patches matter.

Related: OpenZeppelin Contracts Security Patterns — broader treatment of how to use OZ safely.

Related: Solidity Security Checklist Before Mainnet — broader checklist that includes OZ version verification.

Important Notes

This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. Migration steps should always be tested on a fork of mainnet state before being applied to production deployments. Always conduct a professional audit after any major dependency upgrade.

Scan your contract for this vulnerability
Free QuickScan — Unlimited quick scans. No signup required.. No signup required.
Scan a Contract →