Solidity Custom Errors: When to Ditch Revert Strings and How to Decode What Comes Back
Solidity custom errors (0.8.4+) give you typed, ABI-decodable, gas-cheaper failures than revert strings. Here's the syntax, a worked Foundry test, and the real trade-offs.
02 Jul 2025, 07:25 UTC

You've written require(balance >= amount, "Insufficient balance for this transfer"); a hundred times. It works, tests pass, and everyone moves on. But that string costs gas on every revert, it can't be matched programmatically without fragile string comparison, and it tells an integrating contract nothing structured about why the call failed. Since Solidity 0.8.4, there's a better tool: custom errors.
The thesis is simple: if your contract is called by other contracts, tested heavily, or integrated by third parties, custom errors should be your default. Revert strings still have a place in quick prototypes and admin-only guards, but they shouldn't be your error API.
What a custom error actually is
A custom error is a declared, typed failure condition:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
error InsufficientBalance(uint256 available, uint256 required);
contract Vault {
mapping(address => uint256) public balances;
function withdraw(uint256 amount) external {
uint256 bal = balances[msg.sender];
if (bal < amount) {
revert InsufficientBalance({available: bal, required: amount});
}
balances[msg.sender] = bal - amount;
// ... transfer logic
}
}Under the hood, a custom error is ABI-encoded exactly like a function call: a 4-byte selector (the first four bytes of the keccak hash of the signature, e.g. InsufficientBalance(uint256,uint256)) followed by the encoded arguments. That encoding is the whole point. A revert string ships an arbitrary-length ABI-encoded string; a custom error ships a compact selector plus fixed-size arguments. For long messages, the difference in revert-path gas is real — but it depends on message length and compiler version, so measure it in your own contract rather than trusting a blog-post number.
Why integrators care more than you do
Because errors are ABI-encoded, anything with your ABI can decode them deterministically. A test can assert the exact failure. A calling contract can, in principle, branch on the error type. An indexer can aggregate failures by selector instead of parsing free text.
Foundry makes this concrete. In a Foundry test (run with forge test from your project root, no special permissions needed):
function testWithdrawRevertsWithDetails() public {
vault.deposit{value: 1 ether}();
vm.expectRevert(
abi.encodeWithSelector(InsufficientBalance.selector, 1 ether, 2 ether)
);
vault.withdraw(2 ether);
}This asserts not just that it reverted, but the exact error and arguments — something string matching can only approximate. Hardhat's chai matchers offer equivalent support via revertedWithCustomError. If you're still asserting on revert substrings, you're testing the message wording, not the failure condition.
There's a second, quieter benefit: errors can be declared at file level or inside an interface. Put them in your interface and every integrator gets a stable, versioned error surface. This is the pattern newer OpenZeppelin releases adopted, and it's worth copying for any protocol with external integrators.
Verifying the encoding yourself
You don't need to take the selector claim on faith. In a test or script, compute the selector and compare it against real revert data:
bytes4 expected = bytes4(keccak256("InsufficientBalance(uint256,uint256)"));
assertEq(expected, InsufficientBalance.selector);Then catch a low-level call's return data and confirm the first four bytes match:
(bool ok, bytes memory data) = address(vault).call(
abi.encodeCall(Vault.withdraw, (2 ether))
);
assertTrue(!ok);
bytes4 got;
assembly { got := mload(add(data, 32)) }
assertEq(got, InsufficientBalance.selector);To check the gas claim, compile the same guard both ways with a pinned solc version and compare the revert-path gas in forge test --gas-report. Expect the custom error to win clearly with long strings and modestly with short ones.
The trade-offs you should actually weigh
No human-readable message on-chain. Etherscan and wallets show raw hex unless they have your ABI verified. Mitigate this by documenting each error with NatSpec (/// @notice Thrown when ...) and verifying your source. For user-facing dapps, your frontend should map selectors to messages — which you'd want anyway for localization.
Version floor. Custom errors require Solidity 0.8.4+. That's old enough to be a non-issue for new code, but older tooling, forked codebases, or legacy ABIs may not decode them.
Consistency matters more than purity. A codebase that mixes require(..., "...") and custom errors gives integrators two error formats to handle. Pick one convention per contract surface and enforce it in review.
Selector collisions are theoretically possible. Error selectors live in the same 4-byte namespace as function selectors. Don't reuse identical signatures across unrelated errors, and don't name an error identically to a function with the same parameter types.
A practical default
Adopt this rule: errors that other code or integrators might react to get custom errors with useful parameters; internal sanity checks that should never fire can keep short strings or assert-style guards. Declare shared errors in your interface, document them with NatSpec, and write at least one test per error asserting the selector and arguments. Then run a gas comparison on your own contract once — it takes ten minutes and turns "custom errors save gas" from folklore into a number you can defend.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.