# Custom Errors in Solidity: Cheaper, Clearer Reverts
Every contract you write will revert. The question is how. For years the default answer was require(condition, "some string"), and that string quietly cost you gas on every deployment and added bytecode you rarely needed. Custom errors, available since Solidity 0.8.4, give you a cleaner, cheaper, and more expressive way to signal failure. This guide walks through defining them, reverting with arguments, understanding error selectors, decoding them in Foundry tests, and the cases where a plain require is still the right call.
The problem with require strings
A revert reason string is stored in the contract bytecode and ABI-encoded at runtime. Consider this familiar pattern:
function withdraw(uint256 amount) external {
require(amount <= balances[msg.sender], "Insufficient balance");
// ...
}
That literal "Insufficient balance" lives in your deployed bytecode forever. Every string you add inflates deployment cost. At runtime, when the check fails, the EVM ABI-encodes the string into the Error(string) selector, which is more expensive than encoding a compact custom error. The longer and more numerous your messages, the more this adds up across a real codebase.
Defining and reverting with custom errors
A custom error is declared with the error keyword and triggered with the revert statement:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
contract Vault {
mapping(address => uint256) public balances;
error InsufficientBalance(uint256 requested, uint256 available);
error ZeroAmount();
function withdraw(uint256 amount) external {
if (amount == 0) {
revert ZeroAmount();
}
uint256 bal = balances[msg.sender];
if (amount > bal) {
revert InsufficientBalance(amount, bal);
}
balances[msg.sender] = bal - amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "transfer failed");
}
}
Notice what we gained. InsufficientBalance(amount, bal) does not just say something went wrong, it tells the caller exactly how much was requested and how much was available. Those arguments are encoded at runtime and are visible to off-chain tooling, but the parameter names and the descriptive text are not baked into the bytecode as a string.
Custom errors can be declared:
- Inside a contract, scoped to that contract.
- At file level, so multiple contracts and libraries can share them.
- Inside an interface or library, then referenced as
ILib.SomeError.
File-level and shared errors are a nice way to keep failure semantics consistent across a project without duplicating definitions.
Error selectors: what actually goes on the wire
When you revert with a custom error, the EVM returns ABI-encoded data that starts with a 4-byte selector, exactly like a function call. The selector is the first four bytes of the keccak256 hash of the error signature. For InsufficientBalance(uint256,uint256):
bytes4 selector = bytes4(keccak256("InsufficientBalance(uint256,uint256)"));
// equivalently in modern Solidity:
bytes4 selector2 = Vault.InsufficientBalance.selector;
The full revert payload is the selector followed by the ABI-encoded arguments, which is what abi.encodeWithSelector(Vault.InsufficientBalance.selector, amount, bal) would produce. This is identical in spirit to how the built-in Error(string) and Panic(uint256) errors work under the hood. The 4-byte prefix is exactly why the on-chain footprint is so small: a reason string has to encode the whole message, while a custom error encodes a fixed selector plus only the data you choose to pass.
Because selectors are derived from the signature, two errors with the same name but different parameter types are different errors. Renaming an error or changing its parameters changes the selector, which matters if external tools match on it.
Catching custom errors in another contract
You can react to a specific error type using a typed catch:
interface IVault {
error InsufficientBalance(uint256 requested, uint256 available);
function withdraw(uint256 amount) external;
}
contract Caller {
event Shortfall(uint256 requested, uint256 available);
function tryWithdraw(IVault vault, uint256 amount) external {
try vault.withdraw(amount) {
// success path
} catch (bytes memory reason) {
bytes4 sel = bytes4(reason);
if (sel == IVault.InsufficientBalance.selector) {
(uint256 requested, uint256 available) =
abi.decode(_slice(reason), (uint256, uint256));
emit Shortfall(requested, available);
} else {
revert("unknown failure");
}
}
}
}
In practice you read the first 4 bytes to identify the error, then ABI-decode the remaining bytes (everything after the selector) to recover the arguments. The _slice helper above is shorthand for dropping the leading selector before abi.decode. This pattern lets a calling contract make decisions based on why a call failed, not just whether it failed.
Decoding custom errors in Foundry tests
Foundry has first-class support for custom errors, which makes testing them pleasant. To assert that a call reverts with a specific error, use vm.expectRevert with the error selector or the fully encoded error:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../src/Vault.sol";
contract VaultTest is Test {
Vault vault;
function setUp() public {
vault = new Vault();
}
function test_RevertWhen_AmountIsZero() public {
vm.expectRevert(Vault.ZeroAmount.selector);
vault.withdraw(0);
}
function test_RevertWhen_Insufficient() public {
// expect the error WITH its exact arguments
vm.expectRevert(
abi.encodeWithSelector(
Vault.InsufficientBalance.selector,
100,
0
)
);
vault.withdraw(100);
}
}
Two levels of strictness are available:
Vault.ZeroAmount.selector) to assert the error type only.abi.encodeWithSelector(...) to also assert the exact argument values.When a test fails because of an unexpected revert, Foundry decodes the custom error for you in the trace, showing the error name and decoded arguments, as long as the error definition is in scope. You can also surface decoded reverts in any run with verbose traces:
forge test -vvvv
This prints full call traces with decoded custom errors, so a failing InsufficientBalance(100, 0) reads exactly like that instead of as raw hex.
When to still use require
Custom errors are the default choice, but require has not disappeared, and modern Solidity even lets require take a custom error directly. A few guidelines:
- Simple boolean guards with no data can stay as
require(cond, "...")for readability, especially in small contracts or scripts where bytecode size is irrelevant.
- Third-party or low-level call results, like the
require(ok, "transfer failed")above, are a common and perfectly fine use of a short string.
- In recent compiler versions you can write
require(cond, CustomError(arg)), getting the ergonomics ofrequirewith the gas profile of a custom error.
- Use custom errors whenever you want to pass context (amounts, addresses, identifiers), when you have many distinct failure modes, or when external contracts and tools need to branch on the failure reason.
The assert statement is a separate tool: it triggers a Panic(uint256) and is meant for invariants that should never be false, not for input validation.
Practice on Solingo
The fastest way to internalize this is to write reverts, break them on purpose, and watch the decoded output. On app.solingo-blockchain.xyz you can define custom errors, revert with arguments, and run Foundry-style assertions against them in interactive exercises, with the gas and bytecode differences shown side by side against the old require string approach. Try converting a contract full of require strings into custom errors and compare the deployment cost yourself.