Running both is a normal thing to want. A Hardhat repository that has grown deployment scripts, coverage thresholds, and TypeScript tests does not want to throw those away to get forge test. A Foundry repository that grows an application layer does not want to rewrite its integration tests in Solidity to keep them.
It works, and it mostly works by configuration rather than glue code. Hardhat 3 reads remappings.txt, resolves npm and git-submodule dependencies, and runs Foundry-style .t.sol files with forge-std and most cheatcodes. Its documentation calls Hardhat 3 "a full replacement for Foundry", and the compatibility is real enough that you can point both tools at the same directories and get two working test runners.
What is less obvious is how much the two toolchains still disagree once they are looking at the same files.
So I built one repository where both compile the same contracts with the same solc binary and run the same test files, and then measured the disagreements. Three of them matter:
- The compiler settings need a single source of truth, and even then the deployed bytecode differs by exactly 32 bytes.
- The EVM version is the one setting Hardhat's Solidity test runner will not take from that source unless you pass a flag.
- Both tools default their cache to
./cache, and one of the twocleancommands takes the other's cache with it.
None of these are hard to fix. All three are easier to fix before you have a CI pipeline built on top of them.
What each tool is still better at
The reason to keep both is not that either one is incomplete. It is that the strongest parts of each do not overlap.
Foundry keeps the fastest inner loop on Solidity tests, forge script for deployments and broadcasts, Cast for talking to any chain from a shell, and Anvil for everything you want a local node for. I benchmarked the test workloads a few weeks ago: Foundry won every scenario, though on Solidity tests the margin was 12 to 22 percent rather than the multiples older comparisons suggest.
Hardhat keeps the application side: TypeScript tests against a simulated chain, built-in coverage and gas statistics, Ignition deployments, verification, plugins, and tasks. That is the comeback I wrote about — Solidity tests became first-class in Hardhat 3 without TypeScript testing being removed to make room.
The asymmetry shows up in the documentation. Hardhat has a Foundry compatibility reference, a migration guide, and a plugin for Foundry projects. Foundry's documentation touches Hardhat only in passing: a remapping snippet for Hardhat-style imports, a --hardhat flag that switches Forge to the Hardhat project layout, and the hardhat_* RPC aliases Anvil accepts. Coexistence is a feature Hardhat builds and Foundry tolerates.
The project I measured
One repository, one dependency tree, one compiler configuration, both toolchains:
foundry.toml compiler settings for both tools
hardhat.config.ts parses foundry.toml instead of restating it
remappings.txt forge-std/ and oz/, read by both tools
src/ Voucher.sol, VoucherEscrow.sol
test/ Solidity tests: forge test and hardhat test solidity
test/integration/ TypeScript tests: hardhat test nodejs
script/Deploy.s.sol forge script deployment
The contracts are small on purpose: an owner-minted ERC-20 and an escrow that holds deposits until the payer releases them or a deadline passes, both built on OpenZeppelin 5.6.1. Nine Solidity tests cover the contract behaviour, including one fuzz test. Two TypeScript tests drive the deposit-and-release flow the way an application would.
Versions: Foundry 1.5.1 and 1.8.1, Hardhat 3.16.0, Node.js 22.17.0, solc 0.8.36, forge-std 1.16.2, viem 2.56.3. The machine is an Apple M3 Pro with 12 logical CPUs and 36 GB of memory. Everything below is reproducible from examples/articles/how-do-i-use-foundry-and-hardhat-in-the-same-repository in this site's repository, and the raw results are committed next to the harnesses.
Both test runners pass:
$ forge test
9 tests passed, 0 failed, 0 skipped (9 total tests)
$ npx hardhat test --network hardhat
11 passing (9 solidity, 2 nodejs)
(Both outputs are trimmed to their summary lines.)
One dependency tree, resolved twice
Foundry installs dependencies as git submodules under lib/; Hardhat installs them from npm. You do not need both.
I chose npm, because it is the side that already has to exist for Hardhat, and pointed Foundry at it:
[profile.default]
src = "src"
test = "test"
script = "script"
out = "out"
libs = ["node_modules"]
cache_path = "cache/foundry"
solc = "0.8.36"
auto_detect_solc = false
optimizer = true
optimizer_runs = 200
evm_version = "prague"
libs = ["node_modules"] is documented Foundry behaviour, with a warning attached: npm packages may not be designed for Foundry, and the docs prefer git submodules for Solidity libraries. In practice forge-std installs cleanly from GitHub through npm ("forge-std": "github:foundry-rs/forge-std#v1.16.2"), which is also what Hardhat's migration guide recommends.
The other half is remappings. One remappings.txt at the root serves both tools:
forge-std/=node_modules/forge-std/src/
oz/=node_modules/@openzeppelin/contracts/
Hardhat 3 loads every remappings.txt in the project automatically, including ones inside npm packages and submodules, and scopes each one to the directory it lives in. Foundry reads the root file and adds its own inferred entries for anything under node_modules:
$ forge remappings
forge-std/=node_modules/forge-std/src/
oz/=node_modules/@openzeppelin/contracts/
@nomicfoundation/=node_modules/@nomicfoundation/
@openzeppelin/=node_modules/@openzeppelin/
hardhat/=node_modules/hardhat/
That last line is a good illustration of what libs = ["node_modules"] means: Forge now considers Hardhat itself a Solidity library. It is harmless — nothing imports from it — but it is worth knowing that the two tools build different remapping sets from the same directory, which matters in the next section.
If your project has no checked-in remappings.txt and relies on Forge generating them, Hardhat needs the @nomicfoundation/hardhat-foundry plugin instead; version 3.0.4 is the current release for Hardhat 3.
One compiler configuration
Hardhat's migration guide tells you to copy your solc version, optimizer settings, via_ir, and evm_version from foundry.toml into hardhat.config.ts. That works, and it drifts. hardhat.config.ts is TypeScript, so it can read the file instead:
// foundry.toml is the single source of truth for the compiler and the EVM
// version. Hardhat reads it here so the two toolchains cannot drift.
const root = path.dirname(fileURLToPath(import.meta.url));
const foundry = parse(readFileSync(path.join(root, "foundry.toml"), "utf8")) as Record<string, any>;
const selected = process.env.FOUNDRY_PROFILE ?? "default";
const profile = { ...foundry.profile.default, ...(foundry.profile[selected] ?? {}) };
const settings = {
optimizer: { enabled: profile.optimizer === true, runs: Number(profile.optimizer_runs ?? 200) },
evmVersion: profile.evm_version as string,
viaIR: profile.via_ir === true,
metadata: {
bytecodeHash: (profile.bytecode_hash ?? "ipfs") as string,
appendCBOR: profile.cbor_metadata !== false,
},
};
Reading the profile from FOUNDRY_PROFILE means foundry.toml profiles work for both tools, which is how the deterministic build below gets selected.
The defaults are worth spelling out, because they do not match — and because one of them moves. forge config in an empty project reports optimizer = false, optimizer_runs = 200 and bytecode_hash = "ipfs" on both Foundry 1.5.1 and 1.8.1, but evm_version = "prague" on 1.5.1 and evm_version = "osaka" on 1.8.1. Foundry's configuration reference still documents prague. Hardhat, meanwhile, takes the compilation target from solc's own default, which also changes with the compiler version.
So two developers on different Foundry releases in one repository can compile the same contracts for different EVM versions without touching a config file. Restating four values in two files is exactly the kind of thing that stays correct for six months and then silently does not.
Hardhat also accepts a path to a local compiler, so both tools can use the same binary rather than the same version number. In this project Foundry resolves solc = "0.8.36" through its own compiler cache and Hardhat is pointed at that file, and both builds report 0.8.36+commit.8a079791.
The bytecode still differs, by exactly 32 bytes
With the same compiler binary and the same settings — evmVersion, optimizer, viaIR, and metadata all identical in the standard-JSON input each tool sends to solc — the two toolchains do not produce the same contract.
Voucher's deployed bytecode is 2,178 bytes from both. The first 2,135 bytes are identical. Then 32 bytes differ, and the last 11 are identical again.
Those 32 bytes are the sha2-256 digest inside the CBOR metadata section that solc appends. The digest covers the metadata JSON, and that JSON includes the source file hashes keyed by source name — which is where the two tools part company:
| Foundry | Hardhat | |
|---|---|---|
| The contract | src/Voucher.sol |
project/src/Voucher.sol |
| A dependency | node_modules/@openzeppelin/contracts/access/Ownable.sol |
npm/@openzeppelin/contracts@5.6.1/access/Ownable.sol |
| Remappings in metadata | 5 entries, no context prefix | 3 entries, scoped to project/ |
Neither naming scheme is wrong. Hardhat's carries the package version, which is genuinely more useful. But the metadata hash is a function of those strings, so the tools cannot agree while they use different ones.
For most repositories this does not matter: the runtime code is identical, so both artifacts behave the same. It matters in two places. A CREATE2 address is derived from the creation bytecode, so the same contract deployed from each toolchain lands at a different address. And a verification flow that submits one tool's build for a contract deployed from the other's will not produce a full match.
The fix is the same one Foundry's deterministic-deployment guide recommends, and it works for both:
[profile.deterministic]
bytecode_hash = "none"
cbor_metadata = false
Under that profile Voucher is 2,124 bytes from both tools, byte for byte, and so is the creation bytecode. The tradeoff is real — Foundry's guide notes that without the metadata hash you get a partial match rather than a full match when verifying on Sourcify — but if you need the two toolchains to be interchangeable at the bytecode level, this is the setting that gets you there.
The EVM version does not come along
This one cost me the most time, and it is the reason a shared test suite can pass in one runner and fail in the other.
This project pins evm_version = "prague", one fork behind the Osaka upgrade that reached mainnet on 3 December 2025. That is what pinning looks like when you target a chain that has not activated the newest fork, and Foundry's documentation recommends exactly that for compatibility.
test/EvmVersion.t.sol prices a 2048-bit MODEXP call. EIP-7883, one of the Osaka EIPs, raises that price, so the gas the test reports identifies the hardfork it ran on. The results:
| Runner | Configuration | MODEXP gas |
|---|---|---|
forge test |
evm_version = "prague" in foundry.toml |
612,275 |
forge test |
FOUNDRY_EVM_VERSION=osaka |
7,340,638 |
hardhat test solidity |
hardfork set in the config, no --network |
7,340,638 |
hardhat test solidity |
hardfork set in the config, --network hardhat |
612,275 |
hardhat test solidity |
no hardfork in the config, --network hardhat |
7,340,638 |
Foundry's evm_version sets both the compilation target and the EVM the tests run on, which is why the first two rows move together. Hardhat splits those: solidity.settings.evmVersion is the compilation target, and the hardfork the Solidity tests execute on comes from the simulated network — but only if a network is explicitly selected. Reading the Solidity test task in Hardhat 3.16.0, the hardfork is taken from the network config only when --network was passed; otherwise it falls back to the current default, which the configuration reference gives as Osaka for L1.
So the same test file, compiled for Prague by both tools, executes on Prague under Forge and on Osaka under hardhat test solidity. Two settings have to agree to prevent that:
networks: {
// Without this, Solidity tests run on Hardhat's default hardfork, not the
// evm_version Foundry compiled and tested against.
hardhat: { type: "edr-simulated", chainType: "l1", chainId: 31337, hardfork: settings.evmVersion },
},
and every Hardhat test command needs --network hardhat. Once both are in place the two runners agree exactly: 612,275 gas from Forge's revm and 612,275 from Hardhat's EDR, which is a reassuring result in its own right.
If your tests do not touch a repriced precompile you may never notice this. Gas snapshots, gas reports, and anything asserting on a gas number will notice immediately.
Two caches, one directory
Foundry's cache_path and Hardhat's paths.cache both default to ./cache. Left alone, the directory ends up holding solidity-files-cache.json from Foundry and compile-cache.json from Hardhat, which is fine until something cleans it.
Measured on this project, with both tools sharing ./cache:
forge cleanremoves its own cache file and leaves Hardhat's. The nexthardhat buildstill reportsNo contracts to compileand finishes in 505 ms.hardhat cleanremoves the whole directory. The nextforge buildrecompiles all 37 files.
That is a one-directional hazard, and the cost is a cold build: 2,105 ms against 530 ms warm for Hardhat, and roughly 850 ms for Forge on this workload. Small in a terminal, less small in CI, and confusing when a clean in one toolchain slows down the other.
Foundry 1.5.1 and 1.8.1 behave identically here. Splitting the paths — cache_path = "cache/foundry" in foundry.toml, cache: "cache/hardhat" in the Hardhat config — removes the interaction entirely: after forge clean, cache/hardhat/compile-cache.json is still there and the next Hardhat build stays warm.
Both tools also write test-contract artifacts into their own output directory — artifacts/test/ exists after a build, even though Hardhat's compatibility reference describes test artifacts as internal and part of cache/ — so out/, artifacts/, and cache/ all belong in .gitignore.
Splitting the test suite
The interesting part of running both is not that Hardhat can run your Solidity tests. It is that you get to decide which tests belong in which language, and then the two runners cover different ground.
What I ended up with:
- Solidity, run by both: contract behaviour, access control, revert conditions, fuzz tests. These are the tests that only need contracts and cheatcodes, and they are cheapest where the feedback is fastest.
- TypeScript, run by Hardhat: the multi-transaction flow with the ABI, decimals, and event decoding coming from the client. This is the layer where an application's own mistakes live.
The TypeScript test caught one immediately: viem returns checksummed addresses from decoded logs and public getters, while a wallet client's account.address is lowercase, so a naive assert.equal fails. That is not a contract bug, and no Solidity test would ever see it.
One thing that does not travel cleanly is per-test configuration, which lives in NatSpec above the test. I varied one line and recorded what each runner did with it:
| NatSpec line above the fuzz test | forge test |
hardhat test solidity |
|---|---|---|
| none (config says 256) | 256 runs | 256 runs |
forge-config: default.fuzz.runs = 64 |
64 runs | 64 runs |
hardhat-config: default.fuzz.runs = 32 |
256 runs | 32 runs |
Use the forge-config: prefix and both runners honour it. Use Hardhat's and Forge silently ignores it. There is also a sharp edge: Forge parses these lines as TOML, so writing hardhat-config: inside ordinary prose in a doc comment makes forge test fail with an inline-config parse error. I hit that while writing the comment above the test.
Deployments stay with forge script
Hardhat has no direct equivalent of forge script, and its own migration guide says so, pointing at Ignition and tasks instead. In a repository that has both, there is no reason to choose: forge script speaks JSON-RPC, and hardhat node speaks JSON-RPC.
contract Deploy is Script {
function run() external returns (Voucher voucher, VoucherEscrow escrow) {
vm.startBroadcast();
voucher = new Voucher(msg.sender);
escrow = new VoucherEscrow(IERC20(address(voucher)));
vm.stopBroadcast();
}
}
Broadcast against a hardhat node on chain 31337, Voucher landed at 0x5fbdb2315678afecb367f032d93f642f64180aa3. Reading the deployed code back with cast code and with viem's getCode returned the same bytes, and those bytes matched Foundry's artifact exactly — and Hardhat's artifact only under the deterministic profile, which is the metadata hash showing up again where it counts.
VoucherEscrow matched neither artifact until I masked its four immutable ranges, which is the ordinary reason on-chain code differs from a build artifact and worth remembering before you conclude your toolchains disagree.
Is it worth it?
For a repository that already has both, yes, and the cost is smaller than the alternative. The setup is one dependency tree, one remappings.txt, one compiler configuration read from foundry.toml, two cache paths, and a --network flag on every Hardhat test command.
For a new project, I would not start here. Pick the toolchain that fits the work — Foundry for a self-contained protocol repository, Hardhat 3 when there is a real application layer — and add the second one when something concrete is missing. Every setting the two tools share is a setting that can drift, and the drift is quiet: a metadata hash you notice at verification time, a hardfork you notice when a gas assertion breaks.
What surprised me was how close they got. Same compiler binary, same standard-JSON settings, identical runtime bytecode, and gas figures that matched to the unit once both runners were on the same fork. The remaining disagreements are not architectural. They are three configuration keys and a flag.