Custom Errors in Solidity: Lower Gas, Cleaner Code
Solidity 0.8.4 introduced custom errors. They are a direct replacement for require statements with string messages. The benefit is straightforward: lower gas costs, both at deployment and at runtime.
This post explains how custom errors work, how much gas they save and why, and how to migrate existing contracts to use them.
The Problem with String Errors
The traditional pattern for error handling in Solidity looks like this:
contract OldWayErrors {
address public owner;
constructor() {
owner = msg.sender;
}
function restrictedFunction() public view {
require(msg.sender == owner, "Only owner can call this function");
}
}
Every string you pass to require gets embedded in the contract bytecode. The longer the string, the more bytes are stored on-chain. This affects deployment cost directly. Multiply that across a contract with 10 or 20 functions, each with one or more require checks, and the bytecode bloat becomes significant.
At runtime, reverting with a string message means the EVM must ABI-encode that string and return it as revert data. That encoding costs gas on every failed transaction.
How Custom Errors Work
A custom error is declared at the contract level using the error keyword. When a condition fails, you use revert with that error instead of require.
contract NewWayErrors {
address public owner;
error Unauthorized();
constructor() {
owner = msg.sender;
}
function restrictedFunction() public view {
if (msg.sender != owner) {
revert Unauthorized();
}
}
}
Under the hood, Solidity hashes the error signature (for example, Unauthorized()) and takes the first 4 bytes. This is the same mechanism used for function selectors. When the transaction reverts, those 4 bytes are returned as the revert data instead of an encoded string.
4 bytes versus dozens or hundreds of bytes per error. That is where the savings come from.
Gas Savings: Deployment
Consider a contract with several common validation checks:
contract TraditionalErrors {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) public {
require(to != address(0), "Cannot transfer to zero address");
require(balances[msg.sender] >= amount, "Insufficient balance");
require(amount > 0, "Amount must be greater than zero");
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Those three strings total around 80 characters. Each character is one byte in the bytecode. In a real contract with many functions, this adds up fast.
Now the same logic with custom errors:
contract ModernErrors {
mapping(address => uint256) public balances;
error ZeroAddress();
error InsufficientBalance();
error InvalidAmount();
function transfer(address to, uint256 amount) public {
if (to == address(0)) revert ZeroAddress();
if (balances[msg.sender] < amount) revert InsufficientBalance();
if (amount == 0) revert InvalidAmount();
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
Each error declaration contributes only a 4-byte selector to the bytecode. You can also reuse the same error in multiple functions without duplicating anything. Across a full contract, the deployment cost reduction typically falls between 5,000 and 15,000 gas depending on how many string messages you had and how long they were.
Gas Savings: Runtime
When a transaction reverts, the EVM returns revert data to the caller. With a string message, that data includes the full ABI-encoded string. With a custom error, it is just the 4-byte selector.
The runtime saving per revert is smaller than the deployment saving, typically 20 to 50 gas. But for a protocol processing a high volume of transactions where a percentage of them revert (failed transfers, access control checks, slippage limits), that saving compounds across every failed call over the contract's lifetime.
Adding Parameters to Custom Errors
Custom errors support parameters. This lets you include useful context in a revert without the overhead of string formatting.
contract AdvancedErrors {
mapping(address => uint256) public balances;
error InsufficientBalance(uint256 available, uint256 required);
error TransferFailed(address from, address to, uint256 amount);
function transfer(address to, uint256 amount) public {
uint256 balance = balances[msg.sender];
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
The parameters are ABI-encoded and included in the revert data. The base cost is still lower than a string revert, because you avoid the fixed overhead of encoding a string type. And the error is more precise: a caller or interface can decode the exact values that caused the failure.
Adding parameters does increase the revert data size compared to a parameter-less custom error. The savings compared to an equivalent
requirewith a formatted string are still present, but smaller. For the tightest gas optimisation, prefer parameter-less errors on hot paths.
Organising Errors in a Real Contract
For contracts with many error cases, group errors by category at the top of the contract:
contract TokenWithErrors {
// Access control
error Unauthorized();
error NotOwner();
// Transfer validation
error ZeroAddress();
error InsufficientBalance(uint256 available, uint256 required);
error TransferToSelf();
// Contract state
error ContractPaused();
error AlreadyInitialized();
// ... implementation
}
This makes the error surface of a contract easy to read at a glance. Tools like Etherscan decode custom errors automatically, so users and developers still get meaningful information when a transaction fails. The 4-byte selector maps back to the human-readable error name in any ABI-aware tool.
A function with multiple checks reads cleanly with the if-revert pattern:
function complexOperation(address target, uint256 value) public {
if (target == address(0)) revert ZeroAddress();
if (msg.sender != owner) revert Unauthorized();
if (paused) revert ContractPaused();
if (value > maxValue) revert InvalidAmount();
// operation logic
}Testing Custom Errors
In Foundry, use vm.expectRevert with the error selector:
function testUnauthorizedAccess() public {
vm.expectRevert(Unauthorized.selector);
myContract.restrictedFunction();
}
For errors with parameters, encode the full expected revert data:
function testInsufficientBalance() public {
vm.expectRevert(
abi.encodeWithSelector(
MyContract.InsufficientBalance.selector,
0, // available
100 // required
)
);
myContract.transfer(recipient, 100);
}
In Hardhat with Ethers.js, you can match against the error name directly using .revertedWithCustomError():
await expect(
contract.restrictedFunction()
).to.be.revertedWithCustomError(contract, "Unauthorized");Compatibility
Custom errors require Solidity 0.8.4 or later. If your project still targets an earlier compiler version, you cannot use them. Most active projects have moved past 0.8.4, so this is rarely a blocker in practice.
Hardhat, Foundry, and Ethers.js all support custom error decoding. Block explorers handle them correctly. The toolchain support is solid.
One thing to be aware of: if your contract inherits from external libraries or interfaces that still use require with strings, those strings will still appear in the bytecode from those dependencies. Custom errors only replace the errors you write yourself.
Migration Strategy
If you have an existing contract you want to convert, prioritise the highest-value changes first:
- Identify functions that are called frequently or that revert often (access control checks, balance checks, slippage guards).
- Replace those
requirestatements first. - Add parameters to errors where the caller or a frontend needs the failed values to handle the revert properly.
- Replace the remaining require statements for consistency.
There is no need to convert everything at once. A contract can mix require strings and custom errors. The two approaches are compatible at the bytecode level.
Summary
Custom errors are a simple, low-effort optimisation with real impact. Deployment costs drop because string bytecode is replaced by 4-byte selectors. Runtime costs drop because revert data is smaller. Code clarity improves because error names are precise and reusable across functions.
The pattern is well-supported across the Solidity toolchain and has become standard practice in modern contract development. If you are writing new contracts or have the opportunity to audit existing ones, switching to custom errors is one of the easiest improvements you can make.