
Avoiding Expensive Reverts in Solidity with Custom Errors
Why revert cost matters
A revert is triggered when a condition fails and execution must stop. In Solidity, developers commonly write checks like:
require(amount > 0, "Amount must be greater than zero");This is readable, but the string literal is embedded in the contract bytecode. That increases deployment size, and when the revert happens, the EVM must encode and return the string data. If your contract has many such checks, the overhead adds up.
This becomes especially relevant in:
- DeFi protocols with many validation branches
- Token contracts with repeated balance and allowance checks
- Access-controlled systems with multiple role gates
- Batch operations where a single failure can revert the entire transaction
Custom errors avoid most of this overhead by encoding only an error selector and any typed arguments you choose to include.
What custom errors are
Custom errors are declared at contract or file scope using the error keyword:
error InsufficientBalance(uint256 available, uint256 required);
error Unauthorized(address caller);They can then be used with revert:
if (balance < amount) {
revert InsufficientBalance(balance, amount);
}When the transaction reverts, the error data is ABI-encoded in a compact format. Off-chain tools, block explorers, and frontends can decode it if they know the error signature.
Compared with string reverts
| Approach | Deployment size | Revert data size | Readability | Best use case |
|---|---|---|---|---|
require(condition, "message") | Higher | Higher | Very readable | Small scripts, prototypes |
revert("message") | Higher | Higher | Readable | Legacy code, simple checks |
| Custom error | Lower | Lower | Structured | Production contracts |
The key advantage is that custom errors are typed and compact. Instead of shipping a full sentence in bytecode, you ship a selector and optional arguments.
When custom errors are most useful
Custom errors are not only about gas savings. They are most valuable when your contract has many failure cases and you want each one to be explicit.
Good candidates
- Access control failures
Unauthorized(msg.sender)NotOwner(caller)- Input validation
InvalidAmount(amount)DeadlineExpired(deadline, block.timestamp)- State transition checks
AlreadyInitialized()OrderNotFillable(orderId)- Protocol-specific constraints
SlippageExceeded(expected, actual)VaultPaused()
Less compelling cases
If a contract has only one or two simple checks and deployment size is not a concern, the difference may be minor. However, even then, custom errors usually remain a better long-term choice because they scale better as the codebase grows.
A practical example: token transfer validation
Consider a simplified vault that accepts deposits and withdrawals. A string-based implementation might look like this:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
mapping(address => uint256) private balances;
function deposit() external payable {
require(msg.value > 0, "Deposit must be positive");
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be positive");
require(balances[msg.sender] >= amount, "Insufficient balance");
balances[msg.sender] -= amount;
payable(msg.sender).transfer(amount);
}
}Now compare it with a custom-error version:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
error ZeroDeposit();
error ZeroAmount();
error InsufficientBalance(uint256 available, uint256 required);
mapping(address => uint256) private balances;
function deposit() external payable {
if (msg.value == 0) revert ZeroDeposit();
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
uint256 available = balances[msg.sender];
if (available < amount) {
revert InsufficientBalance(available, amount);
}
balances[msg.sender] = available - amount;
payable(msg.sender).transfer(amount);
}
}This version is more explicit and cheaper to deploy. It also gives frontends enough information to display a precise error message without hardcoding string matching logic on-chain.
Best practices for designing custom errors
1. Name errors by failure condition, not by implementation detail
Prefer:
InsufficientBalanceDeadlineExpiredUnauthorized
Avoid vague names like:
BadInputFailedCheckError1
The error name should describe the semantic reason for failure.
2. Include only useful arguments
Arguments should help diagnose the failure or support UI feedback. For example:
error DeadlineExpired(uint256 deadline, uint256 currentTime);This is useful because the caller can see exactly why the transaction failed.
Avoid overloading errors with too much data. Large revert payloads reduce the gas savings and can make decoding harder.
3. Keep errors near the code that uses them
Declare errors at the top of the contract or in a shared interface if multiple contracts need the same definitions. This improves discoverability and makes it easier for tooling to decode them.
4. Use errors consistently across a codebase
If one module uses require(..., "message") and another uses custom errors, the developer experience becomes inconsistent. Standardize on custom errors for production code unless there is a strong reason not to.
5. Match errors to external API expectations
If your contract is used by a frontend or integrator, document the error signatures. Frontends can decode custom errors and present user-friendly messages without relying on brittle string parsing.
Revert patterns: require vs if + revert
Custom errors are usually paired with if statements:
if (conditionFailed) revert SomeError(arg1, arg2);You can still use require for boolean checks, but it is less expressive when you want to attach structured data. For example:
require(msg.sender == owner, "Not owner");can become:
if (msg.sender != owner) revert Unauthorized(msg.sender);The second form is generally preferred because it is clearer, cheaper, and easier to extend.
Practical rule
- Use
requireonly when the condition is trivial and you do not need structured data. - Use custom errors for almost all production validation logic.
Decoding custom errors off-chain
One concern developers often have is whether custom errors are harder to work with in applications. In practice, modern tooling handles them well.
Frontend integration
Libraries such as ethers.js can decode revert data if the ABI includes the error definitions. This means your dApp can show messages like:
- “Insufficient balance: available 1.2 ETH, required 2 ETH”
- “Transaction expired at 1712345678”
instead of a generic failure.
Testing
Custom errors are also easier to assert in tests because they are typed. In Foundry, for example, you can check for a specific revert selector and arguments. This is more robust than matching strings, which can change accidentally during refactoring.
Common pitfalls
1. Reusing one generic error for everything
A single error Failed(); may save a few keystrokes, but it destroys observability. If every failure looks the same, debugging becomes harder and frontends cannot provide meaningful feedback.
2. Adding unnecessary strings to custom errors
Custom errors should not be used like string messages with extra words. Keep them compact and structured. If you need a human-readable explanation, let the frontend map the error to a localized message.
3. Forgetting that revert data still costs gas
Custom errors are cheaper, but not free. If a failure path is common and avoidable, optimize the logic itself rather than relying only on cheaper reverts.
4. Using custom errors where failure should never happen
If a branch indicates an invariant violation, custom errors are fine, but also consider whether the code structure can prevent the branch entirely. Defensive programming is good; unnecessary branching is not.
Choosing the right revert strategy
The following table summarizes practical guidance:
| Scenario | Recommended approach | Reason |
|---|---|---|
| Prototype or throwaway script | require with string | Fast to write, readability matters more than gas |
| Production contract with many checks | Custom errors | Lower deployment and revert cost |
| Public protocol with frontend integration | Custom errors | Easier decoding and better UX |
| Rare, simple validation | Either, but prefer custom errors | Consistency and future-proofing |
| Complex failure states with data | Custom errors with arguments | Structured diagnostics |
In other words, custom errors are the default choice for serious Solidity development.
Migration strategy for existing contracts
If you already have a codebase full of string-based reverts, you do not need to rewrite everything at once. A safe migration path is:
- Identify the most frequently executed validation paths.
- Replace the most expensive string reverts first.
- Add error definitions at contract scope.
- Update tests to assert custom error selectors.
- Document the new error surface for integrators.
This incremental approach gives you immediate savings without introducing unnecessary risk.
Example migration
Before:
require(allowance >= amount, "ERC20: insufficient allowance");After:
error InsufficientAllowance(uint256 allowance, uint256 amount);
if (allowance < amount) {
revert InsufficientAllowance(allowance, amount);
}If the contract is part of a public interface, keep the old behavior in release notes so integrators know the revert format has changed.
Summary
Custom errors are one of the most practical performance improvements available in Solidity. They reduce bytecode size, lower revert overhead, and make failure states more structured and easier to decode. For contracts with repeated validation logic, they are usually a clear upgrade over string-based require messages.
The main takeaway is simple: use custom errors by default in production contracts, keep them specific, and expose enough data for debugging without bloating the revert payload. This gives you better performance and cleaner code at the same time.
