Hardhat 2 now has an end-of-life date, so this stopped being an optional upgrade. Nomic Foundation's announcement says Hardhat 2 will get Glamsterdam support but "will not be upgraded to the Hegota hardfork or any of its EIPs," and that it reaches end of life at whichever comes first: Hegota activating on mainnet, or June 1, 2027. After that, "no further releases should be expected, including for security fixes."
So I built a small but realistic Hardhat 2 project and migrated it, recording every command and every error along the way.
The short answer: on a project this size the migration is a couple of hours of mechanical work, not a rewrite. The contracts do not change at all. The config, the test file and the deployment script do. Nine commands go wrong before it works, and they go wrong in a predictable order.
The longer answer is that eight of those nine exit non-zero and tell you something specific. The ninth exits zero.
What I migrated
The starting point is a Hardhat 2.29.1 project that looks like most of the ones I have seen: a Vault contract and an OpenZeppelin ERC-20, @nomicfoundation/hardhat-toolbox 6.1.2, eight TypeScript tests using ethers, chai matchers, loadFixture and the network helpers, one custom accounts task defined in the config, a scripts/deploy.ts run with hardhat run, plus hardhat-gas-reporter and solidity-coverage.
The tests deliberately touch the parts that behave differently after the migration: custom errors, events, token balance assertions, ether balance assertions, time travel, an unqualified .to.be.reverted, and a raw eth_blockNumber call through the provider.
Versions and machine, for the numbers below:
- Hardhat 2.29.1 (published August 18, 2026) and Hardhat 3.16.0 (published September 7, 2026)
- Solidity 0.8.25 with the optimizer on, 200 runs,
cancun - Node.js v22.17.0, npm 10.9.2
- Apple M3 Pro, 12 logical CPUs, 36 GB, macOS 26.5.1
The whole project, both versions of it, and the scripts that produce every figure here are in the repository under examples/articles/how-do-i-migrate-a-hardhat-2-project-to-hardhat-3. scripts/replay-migration.mjs replays the migration from the Hardhat 2 project one step at a time and asserts that the tree it ends up with is byte-identical to the migrated project that is checked in. It runs 17 steps and currently reports zero failures.
One idea explains most of the errors
Hardhat 2 gives you one network connection and never mentions it. hre.ethers is attached to it, hre.network.provider is it, and loadFixture operates on it.
Hardhat 3 replaces that with a connection manager. You call network.create(), you get a connection object back, and the plugin surfaces live on that object rather than on hre.
The measured values in the diagram come from a script that opened two connections in one process and queried both.
That separation is real, not bookkeeping. I opened two connections in one script, deployed a contract through the first, and then asked both for the block number and for the code at that address. The first reported block 0x1 and an eth_getCode result 3,528 characters long. The second reported block 0x0 and 0x. network.getOrCreate() returned the same connection id twice, which is what you want when several test files should share a chain.
Once that model is in your head, hre.ethers is undefined and does not provide an export named 'loadFixture' stop being surprises and start being the same fact twice.
It also explains a bug my one-file test suite was too small to hit. 1inch's migration write-up reports tests that "work when defined in a single file but fail when behavior functions are extracted to separate modules," with the fixture appearing to run once instead of resetting per test. Their diagnosis is the diagram above: each module that opens its own connection at the top level gets its own chain, so its loadFixture snapshots are not the ones the test file is restoring. If you split fixtures across files, open the connection in one module and export it, or use getOrCreate().
One naming note, because it will bite anyone following an older write-up: that method was called network.connect(). In Hardhat 3.16.0 it is still there but marked deprecated, with the types saying connect "will be removed in a future version of Hardhat" and to use create or getOrCreate instead.
The nine failures, in the order you get them
Every message below is copied from a log the replay script produced. The step numbers are its step numbers.
| # | What you just did | What Hardhat says | Fix |
|---|---|---|---|
| 2 | Bumped hardhat to 3.x, changed nothing else |
Hardhat only supports ESM projects. |
npm pkg set type=module |
| 3 | Made the package ESM, kept the old config | TypeError: Class extends value undefined is not a constructor or null |
Remove the Hardhat 2 plugins and start from an empty config |
| 5 | Copied the old networks block across |
Config error in config.networks.hardhat.type: Invalid discriminator value. Expected 'http' | 'edr-simulated' |
Give every network a type |
| 6 | Added the types | Config error in config.networks.sepolia.url: Expected a URL or a Configuration Variable |
Replace process.env.X ?? "" with configVariable("X") |
| 8 | Ran hardhat test before installing a test plugin |
nothing, and exit code 0 | Install and register a test runner plugin |
| 9 | Installed the recommended toolbox | npm error peer chai@">=5.1.2 <7" from @nomicfoundation/hardhat-ethers-chai-matchers@3.0.11 |
Move to chai 6 |
| 10 | Registered the plugin, ran the old tests | SyntaxError: The requested module '@nomicfoundation/hardhat-network-helpers' does not provide an export named 'loadFixture' |
Take the helpers off the connection |
| 12 | Ran the old deployment script | TypeError: Cannot read properties of undefined (reading 'getSigners') |
Take ethers off the connection |
| 13 | Fixed that, ran it again | ReferenceError: __dirname is not defined in ES module scope |
import.meta.dirname |
Two of those deserve more than a table row.
The one that exits zero
Step 8 is the only failure in the list that does not look like a failure:
No contracts to compile
Running Solidity tests
That is the complete output of hardhat test on a project with eight passing TypeScript tests in test/vault.ts. Exit code 0.
In Hardhat 3 the TypeScript test runner is a plugin. There are two official ones, for Mocha and for Node's built-in test runner, and until you install and register one, hardhat test runs the Solidity tests — of which there were none — and stops. It does not warn that a test/ directory exists and nothing looked at it.
If your CI pipeline is npm ci && npx hardhat test, that is a green build with zero tests. Check the count, not the exit code. The hardhat3 project's test script now prints 11 passing (3 solidity, 8 mocha), and that summary line is the thing worth asserting on.
The process.env idiom stops working
This is the config pattern almost every Hardhat 2 repository has:
sepolia: {
url: process.env.SEPOLIA_RPC_URL ?? "",
accounts: process.env.SEPOLIA_PRIVATE_KEY ? [process.env.SEPOLIA_PRIVATE_KEY] : [],
},
Hardhat 3 rejects it, because an empty string is not a URL. The replacement is a configuration variable:
sepolia: {
type: "http",
chainType: "l1",
url: configVariable("SEPOLIA_RPC_URL"),
accounts: [configVariable("SEPOLIA_PRIVATE_KEY")],
},
The ?? "" fallback existed because Hardhat 2 evaluated the whole config on every command, including hardhat compile, so a missing Sepolia key had to not throw. Configuration variables are resolved lazily, only when something actually uses that network, which is why the fallback is no longer needed. Nomic's configuration reference documents the format, and an official keystore plugin can supply the values from an encrypted file instead of the environment.
What the config and the tests actually look like afterwards
The Hardhat 2 config registered its plugins by importing them for the side effect, and defined its task inline:
import { HardhatUserConfig, task } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";
task("accounts", "Prints the accounts", async (_taskArgs, hre) => {
const accounts = await hre.ethers.getSigners();
for (const account of accounts) {
console.log(account.address);
}
});
The Hardhat 3 version imports the plugin and lists it, and the task is built and then registered:
import { configVariable, defineConfig, task } from "hardhat/config";
import hardhatToolboxMochaEthers from "@nomicfoundation/hardhat-toolbox-mocha-ethers";
const accounts = task("accounts", "Prints the accounts")
.setInlineAction(async (_taskArguments, hre) => {
const { ethers } = await hre.network.create();
for (const signer of await ethers.getSigners()) {
console.log(signer.address);
}
})
.build();
export default defineConfig({
The solidity block moved across without a single character changed, which the migration guide promises and the replay confirms: the first successful hardhat build in the sequence compiles with the Hardhat 2 settings verbatim.
The test file changes at the top and then barely at all. Before:
import { expect } from "chai";
import hre from "hardhat";
import { loadFixture, time, mine } from "@nomicfoundation/hardhat-network-helpers";
const LOCK_PERIOD = 7 * 24 * 60 * 60;
const SUPPLY = hre.ethers.parseUnits("1000000", 18);
const DEPOSIT = hre.ethers.parseUnits("1000", 18);
After:
import { expect } from "chai";
import { parseEther, parseUnits } from "ethers";
import { network } from "hardhat";
const { ethers, networkHelpers, provider } = await network.create();
const LOCK_PERIOD = 7 * 24 * 60 * 60;
const SUPPLY = parseUnits("1000000", 18);
const DEPOSIT = parseUnits("1000", 18);
Below that line, the edits are mechanical: loadFixture(fn) becomes networkHelpers.loadFixture(fn), time.increase becomes networkHelpers.time.increase, mine(5) becomes networkHelpers.mine(5), and the assertion bodies are untouched except for the matchers that now take the connection's ethers as an argument. changeTokenBalances(token, ...) becomes changeTokenBalances(ethers, token, ...), changeEtherBalance(vault, ...) becomes changeEtherBalance(ethers, vault, ...), and a bare .to.be.reverted becomes .to.be.revert(ethers). The Mocha migration page lists those matcher changes. It does not mention loadFixture, which is the one every test file uses.
Pure ethers utilities are better imported from ethers directly. parseUnits never needed a network connection, and importing it from the package rather than reaching through hre.ethers removes the top-level dependency on a connection existing.
Deployment is where the guide stops
The official migration guide covers the config, plugins, tests and custom tasks. It does not cover deployment scripts, and its extendConfig and extendEnvironment section currently reads "We'll add details on how to migrate them soon."
Deployment scripts are mechanically easy — hre.ethers moves onto a connection, __dirname becomes import.meta.dirname — but there is one silent change. In Hardhat 2 a script run without --network reports hardhat. In Hardhat 3 it reports default:
Deploying to default as 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
{
"network": "default",
"chainId": 31337,
"token": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"vault": "0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512"
}
The chain id is the same and the addresses are the same. Only the name changed, because Hardhat 3 always has a network config called default and falls back to it. If you key a deployments file, an artifact path or an environment lookup off the network name, that file quietly starts writing to a new key. Naming the network explicitly gets the old value back: the harness runs the same script a second time with --network hardhat and records identical token and vault addresses under "network": "hardhat".
The better answer for anything beyond a throwaway script is Hardhat Ignition, which the toolbox already installs. The same two contracts as an Ignition module deploy to the same addresses, and Ignition works out for itself that the token has to exist before the vault, because the vault's module future takes the token's:
[ VaultModule ] successfully deployed 🚀
Deployed Addresses
VaultModule#RewardToken - 0x5FbDB2315678afecb367f032d93F642f64180aa3
VaultModule#Vault - 0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512
Nomic's own guide to deploying with scripts is unusually direct about this: scripts "can't recover from errors automatically," "can't be resumed after being updated or fixed (everything will be redeployed!)," and "don't have a standardized format to track and version your deployment results."
Check your plugins before you start
This is the part that decides whether the migration is two hours or two weeks, and it is a question you can answer from the npm registry in about a minute. A plugin whose current release still declares a hardhat@^2 peer dependency has no Hardhat 3 support, whatever its README says.
I checked 26 of the common ones on September 8, 2026. Sixteen support Hardhat 3, eight are Hardhat 2 only, and two are neither.
| Hardhat 2 plugin | State of its current release | What to do |
|---|---|---|
@nomicfoundation/hardhat-toolbox |
latest is 7.0.0, works with neither version |
Use hardhat-toolbox-mocha-ethers or hardhat-toolbox-viem |
@nomicfoundation/hardhat-chai-matchers |
latest is 3.0.0, works with neither version |
Use hardhat-ethers-chai-matchers, bundled in the toolbox |
hardhat-gas-reporter |
2.3.0, peer hardhat@^2.16.0 |
Built in: hardhat test --gas-stats |
solidity-coverage |
0.8.17, peer hardhat@^2.11.0 |
Built in: hardhat test --coverage |
@typechain/hardhat |
9.1.0, peer hardhat@^2.9.9, last published 2023 |
@nomicfoundation/hardhat-typechain, bundled in the toolbox |
@nomiclabs/hardhat-etherscan |
3.1.8, peer hardhat@^2.0.4 |
@nomicfoundation/hardhat-verify 3.x, bundled in the toolbox |
hardhat-tracer |
3.4.0, peer >=2.22.5 <3.x |
No replacement; Hardhat 3 has built-in execution traces |
hardhat-contract-sizer, hardhat-abi-exporter, hardhat-storage-layout |
peer hardhat@^2.x |
No Hardhat 3 release |
hardhat-deploy |
2.0.26, peer hardhat@^3.6.0 |
Works; the 2.x line has declared a Hardhat 3 peer since February 2026 |
@openzeppelin/hardhat-upgrades |
4.1.0, peer hardhat@^3.6.0 |
Works |
Two entries in that table are worth calling out.
The first is that @nomicfoundation/hardhat-toolbox@latest and @nomicfoundation/hardhat-chai-matchers@latest are now deliberate tripwires. Installing either succeeds; loading it prints Warning: You installed the 'latest' version of @nomicfoundation/hardhat-toolbox, which does not work with Hardhat 2 nor 3. and exits 1. Their READMEs point at the migration guide and at the hh2 dist-tag for anyone staying on Hardhat 2. It is a good decision — a loud failure beats a subtle one — but it means a routine dependency bump on a Hardhat 2 repository can break it in a way that looks unrelated to the bump.
The second is hardhat-deploy, the most-cited migration blocker of the lot. 1inch's migration series says flatly that it "is not compatible with Hardhat 3. The plugin relies on Hardhat 2's internal APIs," and replaces it with Ignition. The registry now says something else: hardhat-deploy 2.0.0 shipped on February 18, 2026 declaring a hardhat@^3.1.8 peer dependency, and 2.0.26 followed on August 30, 2026. I installed 2.0.26 into the migrated project and it loaded and registered its deploy task under Hardhat 3.16.0. I did not run a deployment through it, so read that as "the plugin loads," not "your deployment scripts port unchanged."
What you get for the trouble
Two things came out better than I expected and one came out worse.
Solidity tests, on the same contracts. This is the reason the migration is worth doing rather than merely necessary. The migrated project adds a contracts/Vault.t.sol alongside the TypeScript suite, with forge-std cheatcodes and a fuzz test:
/// @notice Any deposit is fully recoverable once the lock period has passed.
function testFuzz_DepositIsRecoverable(uint96 amount) public {
vm.assume(amount > 0 && uint256(amount) <= SUPPLY);
uint256 before = token.balanceOf(address(this));
vault.deposit(amount);
vm.warp(block.timestamp + LOCK_PERIOD);
vault.withdraw();
assertEq(token.balanceOf(address(this)), before);
assertEq(vault.balanceOf(address(this)), 0);
}
hardhat test then runs both suites and reports 11 passing (3 solidity, 8 mocha), with the fuzz test executing 256 runs. Nothing in the Hardhat 2 project could have run that file. This is the change the comeback of Hardhat was about, and it is the part that makes the deadline feel less like a tax.
A much smaller dependency tree. Replacing the Hardhat 2 toolbox stack with the Hardhat 3 one halved the installed packages and removed almost all of the high-severity advisories.

Measured with npm ci followed by npm audit --json in each project, on 2026-09-08. Advisory counts drift: an earlier run the same day put the Hardhat 3 project at 14 rather than 15, because a new adm-zip advisory landed on a package Hardhat itself depends on. The high-severity column is the stable part.
591 packages down to 297, and 16 high-severity advisories down to 1. Some of that is Hardhat 3 needing fewer dependencies, and some of it is that ts-node, typechain and the old coverage and gas tooling are gone. The remaining high-severity advisory is in the new tree, so this is an improvement rather than a clean slate.
Coverage numbers that are not comparable. This is the one that would have caught me out in CI. The same eight tests score 96.15% line coverage under solidity-coverage 0.8.17 and 84.62% under Hardhat 3's built-in --coverage. The tests did not get worse. solidity-coverage marked one line uncovered in Vault.sol; Hardhat 3 marks four — line 34, the body of the owner-only setter that no test calls successfully, plus lines 48, 57 and 63, which are the three if (amount == 0) revert NothingDeposited(); statements whose revert is never taken. Hardhat 3 counts the untaken revert as its own uncovered item on that line, so the number that solidity-coverage reported separately as 64.29% branch coverage is folded into the line figure instead. If you enforce a coverage threshold, recalibrate it rather than trusting the old one.
The gas figures, by contrast, are identical. Six functions appear in both reports and all six match exactly: deposit 108,565, transfer 51,638, approve 46,391, depositEther 43,539, withdraw 36,680, withdrawEther 28,495. So does the deployment gas, and so do the deployed addresses. That is the check I would run first on any migration: whatever else changed, execution did not.
What this does not tell you
One small project on one machine. It has two contracts, no proxies, no diamonds, no multi-chain deployments, no viaIR, no custom hardhat plugin of its own, and no extendConfig or extendEnvironment — which is exactly the case the official guide has not documented yet. It also has one test file, which is why the split-module loadFixture problem above is 1inch's report rather than my measurement.
I did not measure whether Hardhat 3 is faster than Hardhat 2 here, and the timings the harness collects should not be read that way: the Hardhat 3 project compiles an extra Solidity test file against forge-std and runs three more tests, so the two commands are not the same workload. For a controlled comparison, I have benchmarked Foundry against Hardhat 3 on identical work.
I also did not use the official migration skill, which Nomic ships for coding agents and which walks a repository through project setup, config, Solidity tests, source files, test fix-up and script validation, "pausing for review after each." The point of doing it by hand was to find out what the errors actually say. If you are migrating a real repository, running the skill first and then checking its output against this list is probably the faster order. The same page notes that "further manual migration may be necessary" for CI, deployment and specialist scripts.
Is it worth doing now?
Yes, and earlier than the deadline suggests.
The mechanical part is small and bounded, and every error message in it is specific. The part that is not bounded is your plugin list, and that is the thing to check first — before you touch a config file, look up every hardhat-* package you depend on and see what its current release declares. If they are all covered, the migration is an afternoon. If one of them is hardhat-tracer or an internal plugin built on extendEnvironment, the migration is a design decision and you want to know that now rather than in May 2027.
The two things I would carry into any migration regardless of size: assert on your test count rather than your exit code, and compare gas and deployed addresses before and after. The first catches the failure that looks like success. The second proves the only thing that actually had to stay the same.