The first thing to know about migrating from Ethers.js to Viem is that the official guide is written for a version of Ethers you are probably not running. Viem's migration guide is titled "Ethers v5 → viem Migration Guide". It is 1,846 lines long, it is genuinely useful, and it contains zero mentions of v6. Ethers 6.0.0 shipped on 3 February 2023.
That is not a criticism so much as a gap. The guide's 14 sections cover providers, signers, contract calls, and every ABI, byte, and hashing utility. They do not cover error handling — the words "revert" and "catch" do not appear anywhere in the file — and its "Contract Events" section covers only live subscriptions, not historical log queries. queryFilter, getLogs, and fromBlock appear nowhere. The last change to that file was on 19 March 2025, and it was a fix to some RPC URLs.
So I built the mapping myself and measured it. One Solidity contract, one local Anvil chain, both libraries in the same dependency tree, and the same work done twice.
The headline is reassuring and then it isn't. Of 38 paired operations — chain reads, contract reads, writes, log queries, utilities — 28 return values that are strictly equal in both libraries. The port is mostly a rename. The other ten are where the work is, and the awkward part is not their number but their distribution: I took 13 real Ethers idioms and wrote the mechanical Viem translation of each, the kind a careful find-and-replace produces. TypeScript rejected 8 of the 13. The other 5 compiled, ran, and quietly did something different. In plain JavaScript, 9 of the 13 are silent.
Two of those five can lose money.
What I measured
contracts/Ledger.sol is a token-like contract whose every declaration exists to force one porting decision. It exposes a uint8, a uint48, a uint56 and a uint256 so I could find Viem's number/bigint boundary by experiment. It returns a struct, a named tuple, and a dynamic array. It has a function overloaded on arity. It can revert three different ways: a custom error with arguments, a require string, and a compiler-generated Panic. Its Transferred event has two indexed and two non-indexed inputs.
Everything ran against ethers 6.17.0 (published 18 June 2026) and viem 2.56.3 (published 2 September 2026) on Node.js v23.7.0, TypeScript 5.9.3, and Anvil 1.5.1-stable, with Solidity 0.8.25 at 200 optimizer runs, on an Apple M3 Pro. The contract, both implementations of everything, the harnesses, and the raw JSON are in the repository under examples/articles/how-do-i-migrate-from-ethers-js-to-viem.
The paired comparison needed one piece of care to be worth anything. Both sides of every pair run inside an evm_snapshot and evm_revert, with the next block's timestamp pinned, so each library sees a byte-identical chain. My first attempt did not do this, and it produced a result I nearly published: the two libraries appeared to disagree about gasUsed on some runs and agree on others. The cause was in my harness, not in either library. transfer writes uint64(block.timestamp) into two accounts, and an SSTORE that rewrites the same value costs 100 gas where a changing one costs 2,900 — so the same transaction measured 48,152 or 53,752 gas depending on whether the wall clock happened to land on the second already stored. That is 5,600 gas, exactly twice the difference. With the snapshot and the pinned timestamp, five consecutive runs return identical numbers.
One useful thing fell out of the setup itself: both libraries live in the same package.json here and both talk to the same chain without interfering. A migration does not have to be a single commit. You can port one module at a time.
Most of the port really is a rename
Before the interesting differences, the boring result deserves its own paragraph, because it is the one that decides whether the migration is worth starting.
Of the 38 operations, 28 are strictly equal — same JavaScript type, same value, deep-compared. That includes every uint256 read, negative int256 values, bytes32, booleans, strings, dynamic arrays, checksummed addresses out of getAddress, keccak256 over a UTF-8 string, transaction counts, deployed-code lookups, and parseUnits on well-formed input.
Two of those deserve calling out because they are the ones people worry about.
Gas agrees exactly. ledger.transfer.estimateGas(...) and publicClient.estimateContractGas({...}) both returned 53752n for the same call, which is also exactly the gasUsed that the same transaction reported on its receipt through either library. There is no hidden padding on either side.
Addresses agree. Both return EIP-55 checksummed addresses from contract reads and from getAddress, and both reject a mixed-case address whose checksum is wrong. If you have string comparisons on addresses, they will keep working.
Numbers: there is no rule, only a table
The single most common port mistake is assuming one library uses bigint more than the other. Neither does. They disagree in both directions, method by method.
Ethers hands you a number for a block number and a block timestamp; Viem hands you a bigint. Ethers hands you a bigint for the chain id; Viem hands you a number. getTransactionCount and receipt.gasUsed agree.
For contract returns there is at least a rule, and it belongs to ABIType rather than to Viem: int<M> and uint<M> become number when M <= 48 and bigint when M > 48. After two seeded transfers my uint48 counter came back from Viem as 2 and my uint56 counter as 2n, which is exactly that boundary; Ethers returned 2n for both. Ethers has no such split; every integer ABI type decodes to bigint.
Two consequences worth planning around. If your contract uses narrow integer types — a uint32 timestamp, a uint16 fee in basis points, a uint8 decimals — every arithmetic expression touching them changes on the way to Viem. And if you have Ethers code doing 10n ** decimals, it will not compile against Viem, because decimals is now a number. That one is a compile error, which is the good case.
Formatting is where a number becomes a bug
formatUnits is the one operation in the whole set that returns a different value rather than a different type. For 1000000 * 10n ** 18n at 18 decimals, Ethers returns "1000000.0" and Viem returns "1000000". Fractional values agree: both give "1.2345" for 1234500000000000000n. Only the trailing .0 on whole numbers differs.
That is trivial until you have a snapshot test, a string comparison, or a display assertion keyed on the Ethers rendering. Nothing throws.
parseUnits is worse, and it is the reason I would not do this migration without reading every amount-handling path. Given "1.0000000000000000005" — one digit more precision than an 18-decimal token has — Ethers throws NUMERIC_FAULT with the message "too many decimals for format". parseUnits routes through FixedNumber.fromString, whose documentation says it "will throw a NumericFaultError if value cannot fit in format, either due to overflow or underflow (precision loss)". Viem returns 1000000000000000001n. It rounds.
I read the implementation to be sure of the mechanism. Viem's parseUnits delegates to the Value.from helper, whose comment says "round off if the fraction is larger than the number of decimals" and which rounds half-away-from-zero by inspecting the first truncated digit. Viem's parseUnits documentation does not mention it.
So a user-supplied amount that Ethers rejected is now silently rounded. In one direction that is a rounding error of one wei. In the other, if your input path relied on the throw to reject bad input, that validation is gone and you did not touch the validation code.
Reads: structs, tuples, and one place Viem is better
A struct return is the clearest shape change. Ethers gives you a Result, which is array-like with named properties, so account[0] and account.balance both work. Viem gives you a plain object, so only the name works.
async t05(f: Fixture) {
const account = await f.ethersLedger.accountOf(DEPLOYER);
return account[0];
},
export async function run(f: Fixture) {
const account = await f.viemLedger.read.accountOf([DEPLOYER]);
return account.balance;
}
Note the second difference in that pair: Viem takes arguments as an array. accountOf(a) becomes accountOf([a]). That is mechanical and the compiler enforces it.
A named tuple return behaves the other way around: summary() returns a readonly array in Viem, and comparing it against Ethers' Result after Array.from gives strictly equal values. Positional access survives for tuples and breaks for structs, which is not intuitive and is worth knowing before you start.
Then there is one case where the port is an improvement. My credit function is overloaded on arity. In Ethers 6.17.0, calling it by name throws before it reaches the chain:
INVALID_ARGUMENT: ambiguous function description (i.e. matches "credit(address)", "credit(address,uint256)")
You have to name the signature: ledger["credit(address,uint256)"](...). Viem resolves the overload from the arguments you passed and returns the right answer with functionName: "credit". If your codebase is littered with signature-string calls, you can delete them.
Writes: the pre-flight check moves, and that is the expensive one
This is the difference I did not expect, and it is the one I would put at the top of a migration checklist.
In its default configuration, Ethers refuses to broadcast a transaction that will revert. It runs eth_estimateGas while populating the transaction, the node rejects it, and the call throws. Nothing reaches the chain and the nonce does not move. That behaviour is not a feature Ethers advertises; it is a side effect of needing a gas limit — which is also why it disappears the moment you supply one.
Viem's writeContract does the same thing — but only when the account is one the library holds. Its own documentation is explicit: writeContract "internally sends a transaction – it does not validate if the contract write will succeed", and "it is highly recommended to pair it with simulateContract".
I sent the same guaranteed-to-revert transfer() six ways and traced every JSON-RPC call.
Four of the six threw before broadcasting, and the two that did not are the two that never asked for a gas estimate.
Viem with account set to a plain address rather than a local account object sent eth_chainId and then eth_sendTransaction. Anvil filled the gas limit with the whole 30,000,000 block gas limit, mined the transaction, and returned a receipt with status: "reverted", 29,271 gas burned, no logs, and the nonce consumed.
Ethers does the same thing under the same condition. Give a Wallet an explicit gasLimit and it has no reason to estimate: the trace goes straight to eth_sendRawTransaction and produces the identical reverted receipt and the identical 29,271 gas. So this is not a claim about which library is careful. It is a claim about a check that is easy to lose, and Viem's default frontend configuration loses it while Ethers' does not.
That default matters because an address-only account is the shape a browser wallet gives you. The configuration without the check is the one a frontend uses.
One caveat on how bad this is in production: Anvil is not a wallet. Real wallets simulate the transaction before asking the user to sign and typically warn when it will fail, so the user-facing outcome is often a warning rather than a burned nonce. I did not measure any wallet's behaviour here, and I would not rely on it.
The fix is the pattern Viem documents. Wagmi ships the React version of it as useSimulateContract, whose docs say it "can be combined with useWriteContract to reduce the amount of validation required by wallets".
export async function run(f: Fixture) {
const wallet = createWalletClient({ chain: anvil, transport: http(RPC_URL) });
try {
// simulateContract restores the pre-flight check Ethers ran for you.
const { request } = await f.publicClient.simulateContract({
address: f.ledger,
abi: ledgerAbi,
functionName: "transfer",
args: [SECOND, TOO_MUCH, MEMO],
account: DEPLOYER,
});
const hash = await wallet.writeContract(request);
const receipt = await f.publicClient.waitForTransactionReceipt({ hash });
return `sent, status ${receipt.status}`;
} catch {
return "refused before sending";
}
}
Two smaller write differences, both caught by the compiler. writeContract returns a transaction hash, not an object, so tx.wait() becomes waitForTransactionReceipt({ hash }). And a successful receipt reports status: 1 in Ethers and status: "success" in Viem, so every receipt.status === 1 needs rewriting — TypeScript flags it as TS2367, an unintentional comparison, but a JavaScript codebase takes the wrong branch in silence.
Events: the block range is the one that will hurt
Ethers' queryFilter searches from genesis when you give it no block range. Viem's getLogs does not.
I emitted five Transferred logs across five blocks and asked both libraries the same question with no fromBlock or toBlock. Ethers returned all five. Viem returned one — the latest block. Give Viem fromBlock: 0n, toBlock: "latest" and both return five. Filter on both indexed arguments and both return five.
There is nothing to catch this. The types are satisfied, no error is raised, and on a fresh local chain the counts can even look plausible. On a real chain, a port of queryFilter(filter) to getLogs({ address, event }) returns whatever happened in the last block and your history quietly disappears. Viem's getLogs documentation lists the accepted values for fromBlock and toBlock but does not state a default.
The other event differences are smaller.
Viem needs the event definition as an object; Ethers reads it out of the contract's own interface. In TypeScript that object should be as const, or the argument types widen to nothing useful:
export const transferredEvent = {
type: "event",
name: "Transferred",
inputs: [
{ name: "from", type: "address", indexed: true },
{ name: "to", type: "address", indexed: true },
{ name: "value", type: "uint256", indexed: false },
{ name: "memo", type: "bytes32", indexed: false },
],
} as const;
Decoded arguments lose positional access. Ethers returns an EventLog whose args is both array-like and named, so logs[0].args[2] works. Viem's args is named only. blockNumber is a number on an Ethers log and a bigint on a Viem one. Ethers calls the position index; Viem calls it logIndex. Viem also gives you blockTimestamp and eventName, which Ethers does not.
Both libraries refuse to filter on a non-indexed argument, and here Viem refuses earlier. Ethers throws at run time with INVALID_ARGUMENT: cannot filter non-indexed parameters; must be null. Viem rejects it at compile time — TS2353, 'value' does not exist in type — because it builds the allowed keys from the indexed flags in your event definition. Its documentation says the same thing in one line: "Only indexed arguments in event are candidates for args."
If you defeat those types with a cast, the behaviour underneath is worth knowing: Viem builds topics only from the indexed inputs and then filters the decoded arguments in memory. In my measurement it returned the correct single log, while a raw eth_getLogs with the three topics it actually sent returned all five. The answer is right; the topic filter is not what you would assume if you were handing those topics to an indexer.
Errors: the whole shape changes
Ethers puts the decoded revert on the error object. Viem nests it, and you are expected to walk the chain to find it.
Five revert paths, measured through both libraries:
| Revert | Ethers err.reason |
Ethers err.revert.name |
Viem errorName after walk |
Viem reason |
|---|---|---|---|---|
| Custom error, 3 args, on a write | null |
null |
InsufficientBalance |
null |
| Custom error, 1 arg, on a write | null |
null |
AccountFrozen |
null |
revert("Ledger: paused"), on a write |
Ledger: paused |
Error |
Error |
Ledger: paused |
Panic(0x12), on a view call |
Panic due to DIVIDE_BY_ZERO(18) |
Panic |
Panic |
Division or modulo by zero… |
Custom error via staticCall / simulateContract |
InsufficientBalance(address,uint256,uint256) |
InsufficientBalance |
InsufficientBalance |
null |
Two things stand out.
Ethers cannot name a custom error on the write path. All three write cases came back with code: "CALL_EXCEPTION" and shortMessage: "execution reverted (unknown custom error)", with reason and revert both null. The same revert through transfer.staticCall(...) decodes perfectly, with the error name and all three arguments. The reason is structural: the write throws out of eth_estimateGas, and that path does not carry the contract interface that decodes custom errors. Viem decoded the custom error on every path, write and call alike. If you have Ethers code reading err.revert.name in a write handler, check whether it has ever actually fired.
err.reason does not exist on a Viem error. In my trap matrix, the mechanical port of catch (e) { return e.reason } compiled cleanly — catch bindings are unknown, so there is nothing for the compiler to object to — and returned the fallback branch every time. A revert that used to produce a specific user-facing message now produces "unknown reason".
The portable version costs about eight lines:
export async function run(f: Fixture) {
try {
await f.viemLedger.simulate.transfer([SECOND, TOO_MUCH, MEMO], { account: DEPLOYER });
return "no revert";
} catch (e) {
if (e instanceof BaseError) {
const revert = e.walk((err) => err instanceof ContractFunctionRevertedError);
if (revert instanceof ContractFunctionRevertedError) {
const name = revert.data?.errorName ?? revert.reason;
return name ? `${name}(${(revert.data?.args ?? []).join(",")})` : "unknown reason";
}
}
return "unknown reason";
}
}
walk is not optional. The error Viem throws for a reverted contract call is a ContractFunctionExecutionError with the real revert five to seven cause levels down, through ContractFunctionRevertedError, CallExecutionError, ExecutionRevertedError, and RpcRequestError. Reaching for .cause.cause will work until the day the chain gets one level longer.
Note also which field carries what. A custom error's name lands in revert.data.errorName and its arguments in revert.data.args; a require string and a Panic land in revert.reason. A helper that reads only one of the two will silently handle half your reverts.
What TypeScript catches, and what it does not
Here is the whole trap matrix. Each row is an Ethers idiom, its mechanical Viem translation, and what happened when I typechecked that single file with tsc --strict and then ran it against the chain.
| Area | The Ethers idiom | What the mechanical port does | Caught by |
|---|---|---|---|
| Writes | receipt.status === 1 |
takes the failure branch | compiler (TS2367) |
| Chain reads | (await provider.getBlockNumber()) - 3 |
TypeError: Cannot mix BigInt |
compiler (TS2365) |
| Chain reads | chainId === 31337n |
falls through to "other" | compiler (TS2367) |
| Numeric types | raw / 10n ** (await ledger.decimals()) |
TypeError: Cannot mix BigInt |
compiler (TS2365) |
| Reads | (await ledger.accountOf(a))[0] |
returns undefined |
compiler (TS7053) |
| Events | logs[0].args[2] |
returns undefined |
compiler (TS7053) |
| Writes | const tx = await ledger.credit(…); tx.wait() |
TypeError: tx.wait is not a function |
compiler (TS2339) |
| Chain reads | Date.now() / 1000 - block.timestamp |
TypeError: Cannot mix BigInt |
compiler (TS2365) |
| Numeric types | formatUnits(raw, 18) === "1000000.0" |
takes the wrong branch | nothing |
| Numeric types | parseUnits("1.0000000000000000005", 18) |
rounds instead of throwing | nothing |
| Errors | catch (e) { return e.reason } |
returns the fallback string | nothing |
| Writes | ledger.connect(signer).transfer(…) on a doomed call |
broadcasts and burns gas | nothing |
| Events | queryFilter(filters.Transferred()) |
returns the latest block only | nothing |
{
total: 13,
compilerCatchesIt: 8,
throwsAtRunTime: 0,
silentlyDifferent: 5,
noChangeNeeded: 0,
silentWithoutTypeScript: 9,
throwsWithoutTypeScript: 4,
fixedPortsThatAgree: 13,
fixedPortsThatTypecheck: 13
}
Every one of the 13 also has a corrected Viem port in the repository, and the harness refuses to report anything unless all 13 typecheck and agree with the Ethers baseline. That is what makes the failure column trustworthy: the same code, written properly, passes.
Read the last two summary lines together with the first. TypeScript rejected 8 of the 13, but 4 of those 8 would have thrown at run time anyway — mixing bigint and number is a TypeError in plain JavaScript too. So TypeScript's unique contribution is 4 idioms that would otherwise have been silent, and it takes the silent set from 9 down to 5. That is a real argument for doing this migration in TypeScript rather than after it.
The type safety depends on a literal ABI
The eight compiler catches are not free. They require Viem to know your ABI at the type level, and that means an as const literal. If you JSON.parse an artifact at run time and hand the result to getContract, the ABI widens to Viem's generic Abi type and the inference collapses.
I measured what each setup actually rejects, with four deliberately wrong programs typechecked one file at a time:
| Wrong program | Ethers Contract |
Viem, as const ABI |
Viem, ABI widened to Abi |
|---|---|---|---|
Assign a uint256 return to a string |
accepted | rejected | rejected (as unknown) |
| Call a read with a missing argument | accepted | rejected | — |
| Call a method the contract does not have | accepted | rejected | accepted |
| Filter logs on a non-indexed input | accepted | rejected | — |
Ethers rejected none of the four. Viem with a literal ABI rejected all four. Viem with a widened ABI rejected three, and the ones it caught it caught for the weaker reason that every return is unknown — which you will cast away the first time it is inconvenient. A method that does not exist compiles fine.
There is a maintenance angle to this too. The tool that generates as const ABIs is @wagmi/cli, whose latest release on npm is 2.10.0 from 16 February 2026. The equivalent on the Ethers side is TypeChain, whose latest release, 8.3.2, is from 15 October 2023. If typed contract calls matter to you, one of those two paths is being maintained.
The five things to grep for
If you are doing this port, these are the five that will not announce themselves. Everything else the compiler or a TypeError will find for you.
queryFilter(with no second argument. Every one of these needs an explicitfromBlockandtoBlock..reasoninside acatch. Replace with aBaseError.walkhelper that reads bothdata.errorNameandreason.formatUnits(inside a comparison or an assertion. The trailing.0is gone.parseUnits(on anything a user typed. Add your own decimal-length check; Viem will round where Ethers threw.writeContract— or anything reached from a wallet-provided address — without asimulateContractin front of it.
Then a mechanical pass, in this order, because each step shrinks the next: replace the client construction, let tsc drive the argument-array and bigint changes, replace tx.wait(), replace receipt.status === 1, and rewrite the error handling last, since it is the only part with no compiler help at all.
What this does not prove
One contract, one local chain, one pair of versions, one machine.
- Anvil is not a production node and not a wallet. The reverting-write result would look different against a wallet that simulates before signing, and different again against an RPC provider that rejects a transaction its own simulation fails.
- Everything here is measured with a literal ABI and
strictTypeScript. A JavaScript codebase gets the run-time column and none of the compiler column, which is the whole point of the 9-versus-5 comparison, but I did not port a real JavaScript application to check. - The 38 operations are the ones I judged common. They are not a census of either API surface, and neither library's ENS, multicall, account-abstraction, or transport-level features are in scope.
- I did not measure runtime performance or bundle size. For where the two libraries stand on adoption and size, see Ethers vs Viem.
- Version drift is real in both directions. Ethers 6.17.0 was a substantive release. Viem is on 2.56.3 with 3.0.0-next.10 already published on npm as of 14 August 2026, and that prerelease raises the TypeScript floor to 5.9. Anything about type inference in this article is a statement about 2.x.
Would I do the migration
For a new TypeScript frontend, Viem is where the ecosystem is: 21.3 million npm downloads in the month to 6 September 2026 against 14.5 million for Ethers, and it is what Wagmi is built on.
For an existing Ethers codebase, the answer this measurement supports is narrower than "yes". Twenty-eight of 38 operations are a rename, the compiler will walk you through most of the rest, and both libraries can sit in the same project while you go module by module. That is a genuinely cheap migration by the standards of a client-library change.
But it is cheap on the condition that you handle the five silent ones deliberately, and two of them are in code that moves money: an amount parser that rounds where it used to reject, and a write path that broadcasts where it used to refuse. Those are not found by porting file by file and watching the tests go green. They are found by grepping for them first.
If you are not on TypeScript, do that part first. It is the difference between 5 silent behaviour changes and 9.