Migrating Solidity Reverts to Custom Errors: A Practical Guide
Replace require strings with Solidity custom errors (0.8.4+): declaration, revert syntax, Foundry/ethers decoding, gas verification, and migration pitfalls.
05 Aug 2026, 09:16 UTC

Why string reverts cost more than they should
Every require(condition, "Insufficient balance") stores that string in your contract's deployed bytecode and ABI-encodes it on every revert. Since Solidity 0.8.4, custom errors let you replace strings with a compact, typed error selector plus parameters — typically saving gas on both deployment and the revert path, while giving frontend code structured data instead of a message to parse.
This guide walks through converting an existing contract, verifying the change with tests and gas reports, and handling the decoding edge cases that trip teams up after migration.
Prerequisites
- Solidity compiler
^0.8.4or later (custom errors do not exist in earlier versions). - A working project in Foundry, Hardhat, or Remix with tests you can run.
- Basic familiarity with ABI encoding and either ethers.js or viem for client-side decoding.
Step 1: Declare errors at contract level
Custom errors are declared like events, but with the error keyword. Put them at file or contract scope so they appear in the generated ABI:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
error InsufficientBalance(uint256 requested, uint256 available);
error Unauthorized(address caller);
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
if (balances[msg.sender] < amount) {
revert InsufficientBalance(amount, balances[msg.sender]);
}
balances[msg.sender] -= amount;
// ... transfer logic
}
}Two rules to know up front: error names must be unique within a contract and its inheritance hierarchy (no overloading by parameter list), and parameters have no indexed concept — everything is ABI-encoded into the revert payload.
Step 2: Replace require strings with if/revert
The mechanical change is:
// Before
require(amount <= balances[msg.sender], "Insufficient balance");
// After
if (amount > balances[msg.sender]) {
revert InsufficientBalance(amount, balances[msg.sender]);
}Note the condition inverts: require asserts what must be true, while the if guard detects the failure case. This inversion is the most common source of migration bugs — a flipped comparison silently changes behavior. Migrate one error at a time and run the test suite after each change rather than converting the whole file in one pass.
Step 3: Verify with tests and gas reports
Tests should assert the exact error and its arguments, not just "it reverted." In Foundry (run from your project root, no special permissions needed):
function testWithdrawRevertsWithCustomError() public {
vm.expectRevert(
abi.encodeWithSelector(InsufficientBalance.selector, 100, 0)
);
vault.withdraw(100);
}Then compare gas before and after with forge snapshot (or hardhat-gas-reporter on Hardhat). You should see lower deployment cost and lower cost on the reverting path. The savings per revert are modest — often tens to a few hundred gas depending on string length — so the biggest wins come from long revert strings and frequently-hit failure paths. Do not expect dramatic numbers on contracts with short messages.
Also confirm the ABI JSON now contains an entry with "type": "error" and the correct inputs. If it is missing, clients cannot decode the error by name.
Step 4: Decode errors on the client
With ethers.js v6, a caught contract error exposes the decoded fragment when the ABI includes the error:
try {
await vault.withdraw(100);
} catch (err) {
if (err.code === 'CALL_EXCEPTION' && err.info?.errorName === 'InsufficientBalance') {
const [requested, available] = err.info.errorArgs;
showMessage(`Requested ${requested}, available ${available}`);
}
}Exact property names differ between ethers v5, v6, and viem — check your library's error-handling docs rather than assuming. The robust pattern across libraries is: catch the call exception, match the first 4 bytes of the revert data against known error selectors, then ABI-decode the remainder.
Limitations and fallback handling
- Older tooling: some older block explorers and minimal wallets show custom-error reverts as a bare "execution reverted." Keep an off-chain mapping of error selectors to human-readable strings as a fallback.
- Deployed contracts: you cannot add custom errors to an already-deployed contract. Migration means redeployment (or an upgrade if you use a proxy pattern).
- Cross-contract expectations: external callers that string-match your old revert messages will break. Treat the revert interface as part of your API and communicate the change.
- Libraries: avoid custom errors in libraries intended for consumers pinned below 0.8.4.
Adoption strategy
Use custom errors for all new contracts. For existing codebases, migrate incrementally, starting with revert paths that fire often or carry long strings — that is where the gas savings concentrate. Keep require with strings in throwaway prototypes if you like, but anything heading to mainnet benefits from typed errors: cheaper reverts, smaller bytecode, and clients that can react to why a call failed instead of displaying a sentence.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.