Why custom errors matter

Before custom errors, Solidity developers typically used require(condition, "message") or revert("message"). That approach is readable, but it has drawbacks:

  • Revert strings increase bytecode size.
  • Long strings cost more gas at deployment and execution time.
  • Strings are unstructured, so off-chain code must parse text instead of matching a typed error.

Custom errors solve these issues by allowing you to define named error types with parameters:

error InsufficientBalance(uint256 available, uint256 required);

When a revert occurs, the EVM returns the error selector plus ABI-encoded arguments. This is compact, machine-readable, and easy to decode in tooling.

When to use them

Custom errors are especially useful when:

  • A contract has many distinct failure paths.
  • You want to expose precise revert reasons to frontends or indexers.
  • Gas efficiency matters, especially in frequently called functions.
  • You are writing reusable libraries or protocol-level contracts.

They are less useful when a short, human-readable revert string is sufficient for a tiny prototype. In production, custom errors are usually the better default.


Custom errors versus revert strings

The main tradeoff is readability at the call site versus efficiency and structure.

ApproachProsCons
require(..., "text")Simple, familiar, readable in sourceHigher gas, unstructured, larger bytecode
revert("text")Explicit control flowSame drawbacks as strings
error Name(args) + revert Name(...)Compact, typed, easy to decodeSlightly more setup, requires tooling awareness

A useful mental model is this: revert strings are for humans reading source code, while custom errors are for both humans and machines consuming contract failures.


Defining and using custom errors

A custom error is declared at file scope or inside a contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Vault {
    error ZeroDeposit();
    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 {
        uint256 balance = balances[msg.sender];
        if (balance < amount) {
            revert InsufficientBalance(balance, amount);
        }

        balances[msg.sender] = balance - amount;
        payable(msg.sender).transfer(amount);
    }
}

This example shows two common patterns:

  • A parameterless error for a simple invariant violation.
  • A parameterized error for a state-dependent failure.

The second pattern is especially valuable because it exposes the actual values that caused the revert. That makes debugging and testing much easier.


How custom errors are encoded

Custom errors follow the same ABI encoding principles as function calls.

If you define:

error InsufficientBalance(uint256 available, uint256 required);

the revert data contains:

  1. The 4-byte selector derived from the error signature.
  2. ABI-encoded arguments.

This means off-chain systems can identify the exact error by selector and decode the arguments deterministically.

Practical implications

  • Frontends can map selectors to user-friendly messages.
  • Tests can assert on specific error types rather than string fragments.
  • Monitoring systems can classify failures by error name and parameters.

This is much more robust than matching text, which can change during refactoring or localization.


Best practices for designing error APIs

Treat errors as part of your contract’s public interface. Even if they are not callable like functions, they are still observable by integrators.

1. Use descriptive names

Prefer names that describe the failure condition, not the action that failed.

Good:

error Unauthorized();
error DeadlineExpired(uint256 deadline, uint256 currentTime);

Less clear:

error Fail1();
error BadInput();

2. Include useful context, but not everything

Add parameters that help diagnose the failure or support off-chain handling. Avoid bloating the revert payload with unnecessary data.

Good:

error SlippageExceeded(uint256 minOut, uint256 actualOut);

Usually unnecessary:

error SlippageExceeded(
    uint256 minOut,
    uint256 actualOut,
    address user,
    address router,
    uint256 blockNumber,
    bytes calldata path
);

3. Keep errors stable

If external systems depend on selectors or parameter shapes, changing an error signature is a breaking change. Version carefully.

4. Prefer one error per distinct failure mode

Do not overload a single error for unrelated conditions. Clear separation improves testing and observability.


Using custom errors in libraries and shared modules

Custom errors are especially effective in reusable libraries because they let the library communicate precise failures without forcing string handling.

library SafeMathLike {
    error DivisionByZero();

    function safeDiv(uint256 a, uint256 b) internal pure returns (uint256) {
        if (b == 0) revert DivisionByZero();
        return a / b;
    }
}

When the library is used from multiple contracts, the same error can be reused consistently. This is cleaner than duplicating revert strings in every caller.

Namespacing considerations

If you have multiple modules with similar concepts, choose error names carefully. For example:

  • AccessDenied() for authorization failures
  • InvalidRecipient() for address validation
  • DeadlineExpired() for time-based checks

This avoids ambiguity in large codebases and makes logs easier to interpret.


Decoding custom errors off-chain

Most modern tooling can decode custom errors if the ABI is available. This is important for frontends, scripts, and test frameworks.

Example workflow

  1. A transaction reverts with InsufficientBalance(100, 250).
  2. The client receives revert data.
  3. The ABI decoder matches the selector to the error definition.
  4. The client displays a meaningful message such as: “Available balance is 100, but 250 was requested.”

Frontend strategy

A practical frontend pattern is to maintain a mapping from error selectors to UI messages. If the error includes parameters, render them into the message.

For example:

  • Unauthorized() → “You are not allowed to perform this action.”
  • DeadlineExpired(deadline, currentTime) → “The deadline passed at X; current time is Y.”

This is much better than showing raw revert bytes or generic “transaction failed” messages.


Testing custom errors effectively

Custom errors make tests more precise. Instead of checking for a substring in a revert message, assert the exact error type and arguments.

A typical test in a Solidity testing framework or JavaScript test suite should verify:

  • The correct error is thrown.
  • The parameters match expected values.
  • No unrelated revert occurs.

Example with Solidity-style testing

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";

contract VaultTest is Test {
    Vault vault;

    function setUp() external {
        vault = new Vault();
    }

    function testWithdrawRevertsWhenBalanceTooLow() external {
        vm.prank(address(1));
        vm.expectRevert(
            abi.encodeWithSelector(
                Vault.InsufficientBalance.selector,
                0,
                1 ether
            )
        );
        vault.withdraw(1 ether);
    }
}

This test is stronger than checking a string because it validates the exact ABI-encoded error.

Testing tips

  • Use selector-based assertions whenever possible.
  • Test boundary conditions that should trigger each error.
  • Verify that parameter values reflect the actual state at revert time.
  • Avoid broad “any revert” assertions unless the exact error is irrelevant.

Comparing custom errors with require

A common question is whether custom errors replace require entirely. In practice, they complement each other.

Use caseRecommended approach
Simple internal invariant with no contextrevert SomeError()
User-facing validation with useful parametersrevert SomeError(value1, value2)
Very small prototype or throwaway scriptrequire(..., "message") may be acceptable
Shared library or production protocolCustom errors

You can still use require for very short checks, but if the revert reason matters operationally, custom errors are usually superior.

Example conversion

Instead of:

require(msg.sender == owner, "not owner");

prefer:

error NotOwner(address caller, address owner);

if (msg.sender != owner) {
    revert NotOwner(msg.sender, owner);
}

This gives downstream systems enough information to understand the failure without inspecting contract state separately.


Common pitfalls

1. Overusing parameters

More data is not always better. Large revert payloads can become cumbersome and may expose unnecessary internal details.

2. Reusing generic errors too broadly

An error like InvalidInput() can be too vague if many different checks can fail. Prefer specific errors such as InvalidAmount(), InvalidRecipient(), or InvalidSignature().

3. Forgetting external consumers

If your contract is part of a protocol, assume integrators will decode errors. Keep names stable and documented.

4. Mixing error semantics

Do not use the same error for both authorization and validation failures. These are different classes of problems and should be distinguishable.


A practical design pattern for production contracts

A strong pattern is to define a compact error set near the top of the contract and use them consistently throughout the codebase.

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract Escrow {
    error ZeroAmount();
    error Unauthorized();
    error NothingToRelease();
    error TransferFailed();

    address public immutable payer;
    address public immutable payee;
    uint256 public released;

    constructor(address _payee) payable {
        if (_payee == address(0)) revert Unauthorized();
        payer = msg.sender;
        payee = _payee;
    }

    function release(uint256 amount) external {
        if (msg.sender != payer) revert Unauthorized();
        if (amount == 0) revert ZeroAmount();

        uint256 available = address(this).balance;
        if (available < amount) revert NothingToRelease();

        released += amount;

        (bool ok, ) = payee.call{value: amount}("");
        if (!ok) revert TransferFailed();
    }
}

This style keeps failure modes explicit and easy to audit. It also makes it straightforward to document the contract’s behavior for integrators.


Documentation and operational guidance

If your contract exposes custom errors, document them alongside functions and events. A good contract README or NatSpec comment set should explain:

  • Which errors can be thrown by each function.
  • What each parameter means.
  • Whether the error is user-correctable or indicates a contract invariant violation.

This helps frontend teams, auditors, and support engineers interpret failures correctly.

A practical rule is to treat errors like part of your API surface. If you change them, note it in release notes and versioning docs.


Learn more with useful resources