Custom Errors vs require Strings in Solidity
A custom error is a declared type a revert can carry, with typed parameters. A require string is text. What each one sends back, what it costs, and why the style tells you roughly when the file was written.
TL;DR
- A custom error is a declared type that a revert carries back to the caller, with typed parameters. A
requirestring is text. - The difference that matters when reading is that a custom error tells you the numbers, and a string only tells you the category.
- Custom errors arrived in Solidity 0.8.4, so the style a file uses is roughly a date stamp on when it was written.
- A custom error travels as a four byte selector plus ABI-encoded arguments, the same shape as a function call. That is why a tool without the declaration shows you raw bytes.
requirewith a string is still fine. It is one line, it needs no declaration, and in a short file it reads perfectly well.
What is the difference between a custom error and a require string in Solidity?
A custom error is a declared type that a revert carries back to the caller, with typed parameters. A require string is a piece of text. Both stop the call and undo everything. The difference that matters when you are reading is that a custom error tells you the numbers, and a string only tells you the category.
Here is the modern vocabulary. This is the middle of OpenZeppelin's ERC20, the function every transfer, mint and burn passes through:
function _update(address from, address to, uint256 value) internal virtual {
if (from == address(0)) {
// Overflow check required: The rest of the code assumes that totalSupply never overflows
_totalSupply += value;
} else {
uint256 fromBalance = _balances[from];
if (fromBalance < value) {
revert ERC20InsufficientBalance(from, fromBalance, value);
}
Three values go back with the failure: who was sending, what they actually held, and what the transfer needed. A caller that receives this does not have to guess. The error is declared elsewhere as ERC20InsufficientBalance(address sender, uint256 balance, uint256 needed), and those parameter names are the whole explanation.
Now the older vocabulary, from the opening of Uniswap V2's swap:
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
require(amount0Out > 0 || amount1Out > 0, 'UniswapV2: INSUFFICIENT_OUTPUT_AMOUNT');
(uint112 _reserve0, uint112 _reserve1,) = getReserves(); // gas savings
require(amount0Out < _reserve0 && amount1Out < _reserve1, 'UniswapV2: INSUFFICIENT_LIQUIDITY');
Two checks, two strings. INSUFFICIENT_LIQUIDITY tells you the category of refusal and stops there. It does not say how much was asked for or how much the pair held. For those numbers you go and read the reserves yourself.
Same event in both files: a condition failed, the call stopped, the state rewound. What differs is how much the failure is willing to say. If the mechanics of stopping are the part you want, that is require and revert explained; this article is about the saying.
Which one should you use, and what does each cost?
Use a custom error unless the contract must compile on a pre-0.8.4 version, or the file is short enough that a declaration is more ceremony than the message is worth. The gas saving is real but small. The readable, machine-decodable failure is the reason that actually matters day to day.
require(cond, "msg") | revert CustomError(args) | |
|---|---|---|
| What the caller receives | An Error(string) revert carrying the text | The error's four byte selector plus its arguments |
| Do values come back | No, only the text exactly as written | Yes, every parameter, with its type |
| What the deployed code holds | The whole string, once for each distinct message | A selector computed at compile time, no text |
| Cost when it fires | Higher, and it grows with the length of the message | Lower, and roughly flat |
| Compiler versions | Every version of Solidity | 0.8.4 and later |
| In a block explorer | The text, readable with no extra knowledge | The name and values when the ABI is known, raw bytes when it is not |
The last row is the honest cost of the modern style. A string is self-describing: whoever receives it can read it, always, with nothing else to hand. A custom error is readable only by something holding the declaration. In practice that covers nearly everything you use, since verified contracts publish their ABI, but it is why custom errors occasionally feel like a downgrade in a bare transaction trace.
Why do older contracts use require strings instead of custom errors?
Because custom errors did not exist until Solidity 0.8.4, and a great deal of deployed code holding real money was written before that. A contract pinned to an older compiler cannot use them at all. So the style a file uses is partly a date stamp, which makes it a genuinely useful reading signal.
The evidence is on the first line of each file. OpenZeppelin's ERC20:
pragma solidity ^0.8.20;
Uniswap V2's pair contract:
pragma solidity =0.5.16;
Version 0.5.16 predates custom errors by years. There was no choice to make. Uniswap V2 is not using an old style because nobody got round to updating it; it is using the only style the language offered when it was written, and that code is still deployed and still holding billions.
This is a reading habit rather than a judgement. Open an unfamiliar file, see require with short shouty strings, and you are probably in code from before 2021, or code written in that dialect to match its neighbours. See revert with typed errors and you are after it. Neither is a verdict on quality. It is a fact about the calendar, and it tells you which other conventions to expect in the file.
How does a custom error reach the caller?
As a four byte selector followed by its arguments, ABI-encoded, which is exactly the same shape as a function call. The selector is the first four bytes of the hash of the error's signature. A tool that does not hold the error's declaration has no way of turning those bytes back into a name.
That mechanism explains every odd thing you will notice about custom errors in practice.
ERC20InsufficientBalance(address,uint256,uint256) gets hashed, the first four bytes become the selector, and the three arguments are appended in the standard encoding. A require string is the same idea with a fixed selector: the built-in Error(string), which is 0x08c379a0, followed by the text. Tools have hardcoded that one since forever, which is why a string revert is always readable and a custom error sometimes is not.
Two consequences follow, and both come up quickly:
- Renaming an error, or changing a parameter type, changes the selector. The name is not a label attached to the revert, it is the input to a hash. Anything decoding the old signature stops recognising it.
tryandcatchcan match on a specific error. Because the selector identifies which error fired, a calling contract can distinguish "insufficient balance" from "invalid receiver" in code, rather than comparing strings. That is not really possible withrequiremessages, and it is the capability most people underrate.
Where should custom errors be declared?
Either next to the contract that reverts with them, or in a shared interface that several implementations inherit. The second arrangement exists so different contracts agree on the same error, which lets one caller decode failures from many contracts without holding a separate declaration for each.
The local arrangement is the common one. SafeERC20 declares its errors at the top of the library, immediately above the functions that use them:
library SafeERC20 {
error SafeERC20FailedOperation(address token);
error SafeERC20FailedDecreaseAllowance(address spender, uint256 currentAllowance, uint256 requestedDecrease);
Two declarations, and the second one carries three values because a failed allowance decrease has three numbers worth knowing. Errors live in libraries perfectly happily, which fits what a library is allowed to hold.
The shared arrangement is what OpenZeppelin does for ERC-20. The errors are not declared in ERC20 at all. They arrive through an import:
import {IERC20Errors} from "../../interfaces/IERC6093.sol";
And they arrive by inheritance, on the declaration line:
abstract contract ERC20 is Context, IERC20, IERC20Metadata, IERC20Errors {
IERC20Errors is an interface holding nothing but error declarations, one per failure mode, each with doc comments naming what every parameter means. Interfaces may declare errors even though they may not implement anything, which is one of the few things they are allowed besides function signatures. See interface vs abstract contract for the rest of that list.
The payoff is agreement. Any token inheriting IERC20Errors reverts with the same selectors, so a contract that integrates fifty tokens decodes one set of errors rather than fifty. When you find a file whose entire contents are error declarations, that is what it is for.
What does require still do well?
It is one line, it needs no declaration anywhere, and in a short file the message sits right beside the condition it guards. A custom error costs you a declaration at the top and a reference at the bottom, which is a genuine price when the whole file is fifty lines.
Read the Uniswap V2 checks again and notice how well they read:
require(msg.sender == factory, 'UniswapV2: FORBIDDEN'); // sufficient check
The prefix names the protocol, the word names the refusal, and the whole precondition is legible in one line with nothing to look up. Scanning a function's require lines gives you its preconditions in order, the fastest shortcut in how to read a smart contract. A file full of custom errors needs a second stop at the declarations first.
So the sales pitch has limits. Custom errors win clearly when a failure has values worth returning, when a caller needs to distinguish failures in code, and when the same error must be shared across implementations. When the check is a flat yes or no in a file you can read in one sitting, require with a short string is a reasonable call, and a great deal of the code you will read has made it.
Delete the line and ask what breaks
- Replace
revert ERC20InsufficientBalance(from, fromBalance, value)withrequire(fromBalance >= value, "insufficient")and behaviour is identical. What is lost is the three numbers, and with them any chance of a caller handling the failure programmatically. - Rename the error and every tool decoding the old signature stops recognising it, because the name was hashed into the selector rather than attached as a label.
- Delete the
IERC20Errorsimport fromERC20and the file stops compiling, sinceERC20InsufficientBalancewas never declared locally. The declaration line is where it came in. - Lower a modern file's pragma below 0.8.4 and every custom error becomes a syntax error. That single version boundary is the reason the two dialects exist side by side on chain today.
Related questions
What is a custom error in Solidity?
A declared type used with revert, for example error NotOwner(address caller);. It can take typed parameters, and reverting with it sends those values back to the caller alongside an identifier for which error fired.
Are custom errors cheaper than require strings? Yes, in two places. The deployed contract does not have to store the message text, and the revert itself returns four bytes plus arguments rather than a full string. The saving is real but usually modest.
When were custom errors added to Solidity?
In version 0.8.4. Contracts compiled with anything earlier cannot use them, which is why a large amount of deployed code, including Uniswap V2, uses require strings instead.
Why does a block explorer sometimes show a custom error as raw bytes? Because a custom error is delivered as a hashed four byte selector plus encoded arguments. Without the contract's ABI there is nothing to reverse the hash against, so the tool can only show the bytes it received.
Can you use require with a custom error?
Yes, in recent compiler versions. From Solidity 0.8.26 you can write require(condition, MyError(args)). Before that, the custom error form had to be written as if (!condition) revert MyError(args);, which is still the shape you will meet most often.
Where should custom errors be declared in a project? Next to the contract or library that reverts with them, unless several contracts need to agree on the same failure. In that case put them in a shared interface, which is exactly how OpenZeppelin publishes the standard ERC-20 errors.
Where to go next
Both forms do the same thing to the chain: stop the call and undo everything. What separates them is how much the failure is willing to say on the way out. A string names the category. A custom error names the category and hands you the numbers, at the cost of a declaration somewhere and a decoder at the other end.
Knowing which dialect you are reading is the practical takeaway, because it tells you where to look when a call fails and roughly when the file was written. The next step is applying that to code you did not choose, in how to read a smart contract.
Tagged