Why error handling deserves design attention

Smart contracts are immutable, composable, and often called by other contracts or off-chain systems. When something goes wrong, the revert data becomes part of your contract’s public interface. That means error handling affects:

  • Developer experience: easier debugging and integration
  • Gas usage: smaller revert payloads can save gas
  • Protocol safety: callers can distinguish expected failures from critical bugs
  • Composability: upstream contracts can react to specific failure reasons

A well-designed error strategy helps you communicate intent. For example, “insufficient balance” is a normal business rule violation, while arithmetic overflow is usually a programming mistake or an invariant breach.


The three main failure mechanisms in Solidity

Solidity exposes three common revert paths:

MechanismTypical useGas characteristicsBest for
require(condition, "message")Input validation, preconditionsMore expensive due to string dataSimple, human-readable checks
revert CustomError(args)Structured, typed failuresCheaper than revert stringsProduction contracts, libraries, protocols
Panic codesCompiler-generated runtime failuresAutomatic, standardizedInternal bugs like overflow, invalid array access

require()

Use require() for expected conditions that should be checked before proceeding.

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

This is readable, but revert strings cost more gas and can become verbose when used everywhere.

revert CustomError()

Custom errors are the preferred modern approach for most application-level failures.

error NotAuthorized(address caller);

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

Custom errors are compact, typed, and much cheaper than long revert strings.

Panic codes

Panic codes are emitted by the compiler for certain runtime errors, such as:

  • arithmetic overflow/underflow in checked arithmetic
  • division by zero
  • out-of-bounds array access
  • invalid enum conversion
  • failed assert()

These are not usually something you design directly, but you should understand them because they indicate internal correctness issues rather than user-facing validation failures.


Designing a clear error taxonomy

A practical contract should separate errors into categories:

  1. User input errors
  2. Example: insufficient payment, invalid deadline, unauthorized caller

  1. Protocol state errors
  2. Example: sale not started, vault paused, order already filled

  1. Invariant violations
  2. Example: impossible state transitions, accounting mismatch, unreachable branches

A good rule:

  • use custom errors for categories 1 and 2
  • use assert() for category 3 when you want the compiler to signal a bug if an invariant breaks

Example taxonomy

error NotOwner(address caller);
error SaleNotActive(uint256 currentTime, uint256 startTime, uint256 endTime);
error InsufficientPayment(uint256 sent, uint256 required);
error AlreadyClaimed(address account);

This style makes failures self-describing without paying for long strings.


A practical example: token sale with structured errors

The following contract demonstrates a simple sale with explicit failure reasons and a few best practices.

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

contract TokenSale {
    address public immutable owner;
    uint256 public immutable startTime;
    uint256 public immutable endTime;
    uint256 public immutable pricePerToken;

    mapping(address => bool) public claimed;

    error NotOwner(address caller);
    error SaleNotActive(uint256 currentTime, uint256 startTime, uint256 endTime);
    error InsufficientPayment(uint256 sent, uint256 required);
    error AlreadyClaimed(address buyer);

    constructor(uint256 _startTime, uint256 _endTime, uint256 _pricePerToken) {
        require(_startTime < _endTime, "Invalid sale window");
        require(_pricePerToken > 0, "Price must be > 0");

        owner = msg.sender;
        startTime = _startTime;
        endTime = _endTime;
        pricePerToken = _pricePerToken;
    }

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

    function buy() external payable {
        uint256 currentTime = block.timestamp;

        if (currentTime < startTime || currentTime > endTime) {
            revert SaleNotActive(currentTime, startTime, endTime);
        }

        if (claimed[msg.sender]) {
            revert AlreadyClaimed(msg.sender);
        }

        if (msg.value < pricePerToken) {
            revert InsufficientPayment(msg.value, pricePerToken);
        }

        claimed[msg.sender] = true;
        // Delivery logic would go here
    }

    function withdraw() external onlyOwner {
        payable(owner).transfer(address(this).balance);
    }
}

What this example shows

  • Custom errors provide typed, structured failure data.
  • immutable values reduce storage reads for sale configuration.
  • A modifier is used only where it improves readability; the actual revert still uses a custom error.
  • Validation order matters: cheap checks first, state-dependent checks next, external effects last.

When to use custom errors versus revert strings

Custom errors are ideal when the caller may need to inspect the failure programmatically. Revert strings are still useful for quick prototypes or one-off scripts, but they are less efficient and less structured.

ScenarioPreferWhy
Public protocol contractCustom errorsLower gas, typed data, easier integration
Internal tooling or prototypesRevert stringsFaster to write, readable in tests
Library used by many contractsCustom errorsClear API surface and cheaper failures
Rare debug-only checksassert()Signals invariant violations

Practical guidance

  • Use custom errors for anything that can happen in normal operation.
  • Use require() sparingly when a short string is enough and the contract is not gas-sensitive.
  • Use assert() only for conditions that should never be false if the code is correct.

Understanding assert() and panic behavior

assert() is not a user-facing validation tool. It is meant for invariants that should always hold if the contract logic is correct.

function totalClaimed() external view returns (uint256) {
    uint256 total = 0;

    // Example invariant-style logic
    assert(total <= type(uint256).max);
    return total;
}

In real contracts, assert() is more useful in internal accounting or state machine transitions.

Good uses of assert()

  • checking that a state variable cannot exceed a known bound
  • verifying that an internal sum matches a tracked total
  • confirming a branch is unreachable

Bad uses of assert()

  • validating user input
  • checking external conditions
  • replacing normal business-rule failures

If an assert() fails, it usually indicates a bug or broken invariant, not a normal runtime condition.


Error propagation across external calls

When a contract calls another contract, revert data can bubble up. This is important in composable systems such as DeFi protocols, routers, and vaults.

Example

If Contract A calls Contract B and B reverts with InsufficientPayment, A can either:

  • let the revert bubble up unchanged, or
  • catch it and transform it into a higher-level error
try tokenSale.buy{value: msg.value}() {
    // success
} catch Error(string memory reason) {
    // revert string from require()
    revert(reason);
} catch (bytes memory lowLevelData) {
    // custom error or panic data
    revert("External call failed");
}

This pattern is useful, but be careful: catching and rewriting errors can hide useful diagnostics. In many cases, letting the original revert data propagate is better.

Best practice

  • Preserve original revert data when possible.
  • Only translate errors when you need a higher-level abstraction.
  • Avoid swallowing failures silently.

Testing error paths deliberately

Advanced contracts should include tests for both expected failures and internal invariants. Error handling is part of the public API, so it deserves explicit coverage.

What to test

  • unauthorized access
  • invalid state transitions
  • insufficient value or balance
  • boundary conditions such as deadlines and caps
  • invariant failures, if reachable in test scaffolding

Example test intent

Even without a specific framework, the pattern is the same:

  • call a function with invalid input
  • assert that the transaction reverts
  • verify the correct custom error and arguments

This is especially important for custom errors because their arguments are part of the contract’s behavior. If you change an error signature later, downstream tests and integrations may need updates.


Best practices for production contracts

1. Prefer typed custom errors

They are cheaper and easier to integrate than strings.

2. Keep error names precise

Use names that describe the failure, not the implementation.

Good:

  • InsufficientPayment
  • SaleNotActive
  • NotAuthorized

Less helpful:

  • BadInput
  • InvalidState
  • Failed

3. Include relevant context

If a caller may need to debug or branch on the failure, include values such as:

  • caller address
  • expected versus actual amount
  • current timestamp
  • current state index

4. Don’t overuse require() strings

Long messages increase deployment and runtime cost. Reserve them for quick scripts or temporary debugging.

5. Use assert() only for invariants

If a condition can be triggered by user input, it is not an invariant.

6. Document failure modes

Your NatSpec comments should explain when functions revert and with which errors. This is especially valuable for SDK authors and front-end teams.

/// @notice Buys one sale allocation.
/// @dev Reverts with SaleNotActive, AlreadyClaimed, or InsufficientPayment.
function buy() external payable { ... }

A compact decision guide

NeedRecommended tool
Human-readable quick validationrequire()
Efficient, typed production errorsrevert CustomError(...)
Internal invariant checkingassert()
External call failure handlingtry/catch
Debugging in testsCustom errors with arguments

This decision model keeps your code consistent and makes failure behavior easier to maintain as the contract evolves.


Common mistakes to avoid

Reverting too late

Validate inputs before expensive work or state mutation. Failing early saves gas and reduces complexity.

Using strings for everything

Revert strings are convenient, but they scale poorly in larger systems.

Catching and hiding all errors

If you wrap every failure in a generic message, you lose the ability to diagnose real issues.

Confusing business rules with bugs

A missing allowance is not an invariant violation. An impossible accounting mismatch is.

Forgetting error compatibility

If external integrators rely on your custom errors, changing names or arguments can be a breaking change.


Conclusion

Advanced Solidity error handling is about more than stopping execution. It is about defining a clear, efficient, and composable failure model for your contract. Custom errors should be your default for application-level reverts, require() remains useful for simple checks, and assert() should be reserved for invariants that indicate bugs if violated.

When you treat errors as part of your contract’s API, you get better gas efficiency, clearer integrations, and more maintainable code.

Learn more with useful resources