Why custom errors matter

Before custom errors, developers typically reverted with strings:

require(amount > 0, "Amount must be greater than zero");

This works, but it has drawbacks:

  • The string is stored in bytecode, increasing deployment size.
  • Revert strings consume more gas than structured errors.
  • Strings are harder to inspect programmatically in tooling and tests.
  • Repeated messages can bloat contracts with many checks.

Custom errors solve these issues by defining named error types with optional parameters:

error InvalidAmount(uint256 amount);

Then revert with:

revert InvalidAmount(amount);

This approach is more compact, easier to standardize, and often more informative when the error includes context values.


When to use custom errors

Custom errors are most useful when a revert condition is:

  • Frequent, such as input validation or balance checks
  • Shared across multiple functions
  • Better expressed with structured data than a plain string
  • Important for debugging, monitoring, or off-chain decoding

Common examples include:

  • Unauthorized access
  • Invalid token amounts
  • Deadline expiry
  • Insufficient balance or allowance
  • Invalid state transitions

They are less useful when a revert is extremely rare and the message is only needed for quick local debugging. Even then, custom errors are usually still a good default.


Basic syntax

A custom error is declared at contract scope:

error NotOwner(address caller);
error DeadlinePassed(uint256 deadline);

You can revert with it directly:

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

You can also use it inside modifiers and internal functions.

Example contract

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

contract Vault {
    address public owner;
    mapping(address => uint256) public deposits;

    error NotOwner(address caller);
    error ZeroDeposit();
    error InsufficientBalance(uint256 requested, uint256 available);

    constructor() {
        owner = msg.sender;
    }

    function deposit() external payable {
        if (msg.value == 0) revert ZeroDeposit();
        deposits[msg.sender] += msg.value;
    }

    function withdraw(uint256 amount) external {
        uint256 balance = deposits[msg.sender];
        if (amount == 0) revert ZeroDeposit();
        if (balance < amount) revert InsufficientBalance(amount, balance);

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

    function sweep() external {
        if (msg.sender != owner) revert NotOwner(msg.sender);
        payable(owner).transfer(address(this).balance);
    }
}

This contract communicates failure reasons clearly without embedding long strings in the bytecode.


Custom errors vs require strings

The main difference is not just style; it affects contract size and how errors are consumed.

ApproachExampleProsCons
require(..., "message")require(x > 0, "bad input")Familiar, simpleLarger bytecode, less structured
revert ErrorName(args)revert InvalidInput(x)Compact, structured, testableRequires decoding in tooling
assert(...)assert(x == y)For internal invariantsNot for user input, triggers panic

Practical guidance

Use custom errors for:

  • User-facing validation failures
  • Access checks
  • State guards
  • External call preconditions

Use assert only for invariants that should never fail if the contract is correct.

Use require strings sparingly, mainly when:

  • You are prototyping
  • You need a quick temporary message
  • The contract is tiny and the message is not repeated

For production code, custom errors are usually the better default.


Designing meaningful error types

A good custom error should tell you what failed and, when useful, include the values involved.

Good patterns

error Unauthorized(address caller);
error InvalidAmount(uint256 amount);
error DeadlineExpired(uint256 deadline, uint256 timestamp);
error SlippageExceeded(uint256 expected, uint256 actual);

These errors are useful because they:

  • Name the failure condition precisely
  • Include context needed for debugging
  • Can be decoded by off-chain systems

Avoid vague errors

error Failed();
error Invalid();
error Error1();

These do not help much during debugging or monitoring. If you need to inspect logs or decode revert data, vague names become a burden.

Keep errors domain-specific

Instead of one generic error for everything, define errors around the contract’s business logic:

  • InsufficientLiquidity
  • PositionNotHealthy
  • OrderAlreadyFilled
  • UnsupportedAsset

This makes code easier to read and helps frontends present better messages.


Reusing errors across functions

One advantage of custom errors is consistency. If the same condition appears in multiple functions, define the error once and reuse it.

error NotAuthorized(address caller);

function mint() external {
    if (msg.sender != owner) revert NotAuthorized(msg.sender);
    // ...
}

function burn() external {
    if (msg.sender != owner) revert NotAuthorized(msg.sender);
    // ...
}

This reduces duplication and ensures the same revert reason is used everywhere.

For larger systems, you can group related errors by domain in a single file or interface-style module. That helps teams maintain consistent naming across contracts.


Using custom errors with modifiers

Modifiers are a natural place for reusable checks, and custom errors fit well there.

modifier onlyOwner() {
    if (msg.sender != owner) revert NotOwner(msg.sender);
    _;
}

Then apply it to functions:

function pause() external onlyOwner {
    paused = true;
}

This keeps the function body focused on business logic while preserving a precise revert reason.

Best practice

If a modifier can fail for multiple reasons, prefer specific errors over a generic one. For example:

error ContractPaused();
error NotOwner(address caller);

This makes it easier to distinguish between authorization failures and operational state failures.


Decoding custom errors off-chain

Custom errors are not just for Solidity developers. They are also useful for frontend apps, indexers, and test frameworks.

When a transaction reverts, the error data can be decoded off-chain if the ABI includes the error definition. This allows applications to show user-friendly messages or trigger specific UI flows.

Example use case

A frontend calling a DEX contract might decode:

  • SlippageExceeded(expected, actual) to suggest adjusting tolerance
  • DeadlineExpired(deadline, timestamp) to prompt the user to resubmit
  • InsufficientBalance(requested, available) to show the missing amount

This is more actionable than a generic “transaction failed” message.

Testing benefit

In Solidity tests or JavaScript test suites, you can assert on the exact error type instead of matching a string. That makes tests less brittle and more precise.


Common pitfalls

1. Overusing parameters

Parameters are helpful, but too many can make errors noisy.

error InvalidOrder(uint256 a, uint256 b, uint256 c, uint256 d);

This may be harder to interpret than a smaller error set. Include only the values that matter for debugging or UX.

2. Duplicating the same error under different names

Avoid defining multiple errors that mean the same thing:

error NotAllowed();
error Forbidden();
error AccessDenied();

Pick one naming convention and use it consistently.

3. Using custom errors for internal invariants that should never fail

If something should be impossible in correct code, assert may be more appropriate. Custom errors are best for expected failure paths, not invariant violations.

4. Forgetting ABI exposure

If your frontend or test suite needs to decode the error, make sure the contract ABI includes the error definitions. Most modern tooling handles this automatically, but it is worth verifying in multi-contract setups.


A practical pattern for production contracts

A clean production contract often uses a small error catalog at the top:

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

contract Escrow {
    address public immutable payer;
    address public immutable payee;
    uint256 public immutable releaseTime;
    bool public released;

    error NotPayer(address caller);
    error NotPayee(address caller);
    error TooEarly(uint256 releaseTime, uint256 currentTime);
    error AlreadyReleased();

    constructor(address _payee, uint256 _releaseTime) payable {
        payer = msg.sender;
        payee = _payee;
        releaseTime = _releaseTime;
    }

    function release() external {
        if (released) revert AlreadyReleased();
        if (msg.sender != payer && msg.sender != payee) revert NotPayer(msg.sender);
        if (block.timestamp < releaseTime) revert TooEarly(releaseTime, block.timestamp);

        released = true;
        payable(payee).transfer(address(this).balance);
    }
}

This pattern works well because:

  • Errors are declared once and easy to scan
  • Each revert has a specific meaning
  • The contract remains readable even as it grows

Migration strategy from revert strings

If you have an existing codebase, you do not need to rewrite everything at once. A gradual migration works well.

Step 1: Identify repeated revert messages

Look for messages used in multiple places:

require(msg.sender == owner, "Not owner");
require(admin == msg.sender, "Not owner");

These are strong candidates for a shared custom error.

Step 2: Replace high-frequency checks first

Start with:

  • Access control
  • Input validation
  • Balance and allowance checks
  • Deadline and state guards

These usually provide the biggest gas and clarity improvements.

Step 3: Keep external behavior stable

If your frontend or tests depend on revert strings, update them to decode custom errors. This is usually a one-time change and worth the long-term benefit.

Step 4: Standardize naming

Adopt a naming convention such as:

  • NotOwner
  • InvalidAmount
  • DeadlineExpired
  • InsufficientBalance

Consistency matters more than any single naming style.


Best practices checklist

  • Declare custom errors at contract scope
  • Use specific names that describe the failure
  • Include relevant context values, but only when useful
  • Prefer custom errors over revert strings in production code
  • Reuse the same error across related checks
  • Decode errors in tests and frontends for better UX
  • Use assert only for true invariants
  • Keep the error set small and consistent

Conclusion

Custom errors are a simple but high-impact Solidity feature. They improve readability, reduce bytecode size, and make failure handling more structured for both on-chain and off-chain consumers. For most production contracts, they should be the default way to signal expected revert conditions.

If you are building contracts with repeated validation logic, user-facing transactions, or complex state transitions, adopting custom errors early will make your codebase cleaner and easier to maintain.

Learn more with useful resources