Solidity Custom Errors: Cut Revert Gas Costs by 500–800 Units
Custom errors replace revert strings with typed, named parameters — saving ~500–800 gas per revert. Declare them in a shared file, trigger with revert ErrorName(args), and frontends decode automatically via the ABI. Includes a worked token example, gas comparison table, and verification steps.
04 Oct 2025, 17:46 UTC

The Problem: Revert Strings Waste Gas
Every require(false, "Insufficient balance: need 100, have 50") allocates a dynamic string in memory, expands the memory region, and copies characters — costing roughly 500–800 extra gas per revert. On mainnet at 20 gwei, that’s $0.02–0.03 per failed transaction; at scale it adds up. Custom errors, introduced in Solidity 0.8.4, replace the string with a typed, named error that encodes its parameters directly into the revert data payload — no string allocation, no memory expansion.
Useful Takeaway
Declare errors at file or contract scope, trigger them with revert ErrorName(arg1, arg2), and frontends (ethers.js, viem) decode them automatically when the ABI includes the error fragment. Expect ~500–800 gas savings per revert versus an equivalent require string.
Worked Example: Token Transfer with Insufficient Balance
Create a shared error library so multiple contracts use identical selectors and documentation.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// errors.sol — import this from every contract
// @custom:error InsufficientBalance emitted when sender lacks funds
error InsufficientBalance(uint256 required, uint256 available);
error Unauthorized(address caller, address owner);
Now a minimal token contract that uses the error:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./errors.sol";
contract SimpleToken {
mapping(address => uint256) public balanceOf;
address public owner;
constructor() {
owner = msg.sender;
balanceOf[msg.sender] = 1_000_000;
}
function transfer(address to, uint256 amount) external {
uint256 available = balanceOf[msg.sender];
if (available < amount) {
revert InsufficientBalance(amount, available);
}
balanceOf[msg.sender] = available - amount;
balanceOf[to] += amount;
}
function mint(address to, uint256 amount) external {
if (msg.sender != owner) {
revert Unauthorized(msg.sender, owner);
}
balanceOf[to] += amount;
}
}
What Happens at Compile Time
The compiler generates an ABI entry for each error:
{
"type": "error",
"name": "InsufficientBalance",
"inputs": [
{"name": "required", "type": "uint256"},
{"name": "available", "type": "uint256"}
]
}
The selector is the first four bytes of keccak256("InsufficientBalance(uint256,uint256)") — here 0xcf479181. When revert InsufficientBalance(100, 50) executes, the EVM returns revert data: 0xcf479181 followed by the ABI-encoded uint256 values (32 bytes each). No string, no memory allocation.
Frontend Decoding with ethers.js v6
Assuming you have the full contract ABI (including the error fragment):
import { Interface, Contract, ethers } from "ethers";
const abi = [ /* full JSON ABI from compilation output */ ];
const iface = new Interface(abi);
const contract = new Contract(address, abi, provider);
try {
await contract.transfer(recipient, 100);
} catch (e) {
// e.data contains raw revert bytes
const decoded = iface.parseError(e.data);
// decoded = { name: 'InsufficientBalance', args: [100n, 50n] }
console.log(`Need ${decoded.args[0]}, have ${decoded.args[1]}`);
}
viem works identically: decodeErrorResult(abi, data) returns the same structured object.
Gas Comparison: Require String vs Custom Error
Two equivalent functions compiled with --via-ir --optimize (Solidity 0.8.20, optimizer runs 200):
function requireStyle(uint256 amount) external pure {
require(amount > 100, "Amount must exceed 100");
}
error AmountTooLow(uint256 amount);
function customErrorStyle(uint256 amount) external pure {
if (amount <= 100) revert AmountTooLow(amount);
}
Expected revert gas (measured via forge test --gas-report on a local Anvil fork):
| Style | Revert Gas (approx.) | Difference |
|---|---|---|
require(..., "string") | ~2,850 | baseline |
revert CustomError(...) | ~2,150 | ~700 less |
The exact delta depends on parameter count and optimizer settings, but the 500–800 range is consistent across compiler versions 0.8.4+.
Limitations and Common Mistakes
Cannot Use with require()
require(condition, CustomError(args)) is a syntax error. You must write:
if (!condition) revert CustomError(args);
No Overloading
Two errors named InsufficientBalance with different parameter types cause DeclarationError: Identifier already declared. Choose distinct names (InsufficientBalance, InsufficientAllowance).
ABI Completeness Is Mandatory
If you compile a single file with solc --abi and omit the imported errors.sol, the error fragment disappears from the ABI. Frontends then receive raw hex and cannot decode. Always ship the full combined JSON ABI (e.g., forge build --json or Hardhat’s artifacts/ output).
Cross-Contract try/catch
A try/catch block only decodes custom errors defined in the same compilation unit. Calls to external contracts with different compiler runs yield raw bytes in the catch clause — plan for this when building upgradeable proxies or multi-contract systems.
Minimum Compiler Version
Projects on Solidity <0.8.4 must bump pragma solidity ^0.8.4 (or higher) and run the full test suite. Watch for selector collisions: a custom error’s 4-byte selector could match an existing function selector or revert-string hash, though the probability is low.
Verification Checklist
- Compile with full ABI:
forge build --json > combined.jsonthenjq '.abi[] | select(.type=="error")' combined.jsonlists every error with its inputs. - Deploy to local node:
anvilin one terminal,forge script Deploy --rpc-url http://localhost:8545 --broadcastin another. - Trigger revert and decode:
cast send $CONTRACT "transfer(address,uint256)" 0xDead... 1000 --rpc-url http://localhost:8545 --private-key $KEYshould revert; capture the raw revert data and runcast abi-decode "InsufficientBalance(uint256,uint256)" $DATAto confirm named parameters. - Gas report:
forge test --gas-report --match-test testTransferRevertshows the ~700 gas gap between the two styles.
When Not to Switch
If your codebase already uses revert strings consistently and reverts are rare (e.g., admin-only functions), the migration effort may outweigh the savings. Custom errors shine in high-frequency user-facing paths: transfers, swaps, minting, access control checks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.