Error Handling in Solidity: require, revert, assert, and Custom Errors
Solidity gives you four ways to stop execution when something goes wrong: require, revert, assert, and custom errors. Each one serves a different purpose. Using the wrong one does not break your contract, but it makes your code harder to read and costs more gas than necessary.
This post explains what each mechanism does, when to reach for it, and how they compare in terms of gas cost.
What Happens When an Error Fires
Before Solidity 0.8.0, failed operations could silently continue or produce unpredictable results. That changed in 0.8.0. Now, all four error mechanisms do the same thing at the EVM level: they revert the transaction, roll back every state change made during that call, and refund the remaining gas to the caller.
The difference between them is not what they do when they fail, it is what they signal to readers of your code, and how much gas the failure itself costs.
require: Validate Inputs and Preconditions
Use require to check conditions that depend on external input or state that your contract does not control. The pattern is: if this condition is not met, there is no point continuing.
function withdraw(uint amount) public {
require(amount > 0, "Amount must be positive");
require(balance[msg.sender] >= amount, "Insufficient balance");
balance[msg.sender] -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
The first two require calls validate inputs before any state is modified. The third one verifies the result of an external call after it returns. All three are valid uses.
When require fails, it reverts the transaction and returns the error string to the caller. This string is what appears in most wallets and block explorers when a transaction fails.
Good places to use require:
- Checking
msg.sender,msg.value, or function arguments - Verifying return values from external calls (
token.transfer,call, etc.) - Enforcing access control at the start of a function
revert: Explicit Control Flow
revert does the same thing as a failing require, it stops execution and rolls back state. The difference is where you can place it.
require works well at the top of a function where you can check a single condition. Inside nested if statements, it gets awkward. revert fits naturally there.
function buyTicket(uint ticketId) public payable {
if (msg.value < ticketPrice) {
revert("Payment too low");
}
if (tickets[ticketId].sold) {
revert("Ticket already sold");
}
tickets[ticketId].owner = msg.sender;
tickets[ticketId].sold = true;
}
You can also call revert() without a message. This saves a small amount of gas because no string is encoded into the revert data. The tradeoff is that the caller gets no information about what went wrong, useful only in cases where the revert reason is obvious from context.
assert: Internal Invariant Checks
assert is for conditions that should never be false if your contract logic is correct. It checks internal invariants, not user inputs, not external state, but properties that your own code must maintain.
function transfer(address to, uint amount) public {
uint senderBefore = balance[msg.sender];
uint recipientBefore = balance[to];
balance[msg.sender] -= amount;
balance[to] += amount;
// Total balance must be conserved
assert(balance[msg.sender] + balance[to] == senderBefore + recipientBefore);
}
If an assert fires in production, it means your contract has a bug. It is not for validating user input. It is a signal to yourself and to auditors: "this must always be true, if it is not, something is fundamentally broken."
Before Solidity 0.8.0, a failed
assertconsumed all remaining gas. In 0.8.0 and later, it behaves likerequirein terms of gas refunds. If you are working with older contracts, be aware of this difference.
Custom Errors: Gas-Efficient Error Handling
Custom errors were introduced in Solidity 0.8.4. They give you the control of revert with significantly lower gas cost compared to string-based errors.
Define them at the file or contract level, then use them with revert:
error InsufficientBalance(uint available, uint requested);
error Unauthorized(address caller);
contract Wallet {
mapping(address => uint) public balance;
address public owner;
function withdraw(uint amount) public {
if (msg.sender != owner) {
revert Unauthorized(msg.sender);
}
if (balance[msg.sender] < amount) {
revert InsufficientBalance(balance[msg.sender], amount);
}
balance[msg.sender] -= amount;
}
}
Custom errors can carry parameters. When the transaction reverts, the caller receives typed data, the exact values that caused the failure, rather than a plain string. This makes debugging easier on both the contract side and the frontend side.
The gas saving comes from how Solidity encodes errors. A string like "Insufficient balance" is stored in the contract bytecode and encoded into the revert data at runtime. A custom error uses a 4-byte selector (the first four bytes of the keccak256 hash of the error signature), plus ABI-encoded parameters. This is significantly cheaper, especially for errors with long messages.
Custom errors cost roughly 2,000 gas less than an equivalent require with a string message. For a function called thousands of times per day, that is real money.
Choosing the Right Tool
| Mechanism | Use when |
|---|---|
require | Validating inputs, checking preconditions, verifying external call results |
revert | Complex conditional logic where require would be awkward |
assert | Checking invariants that must always hold if your code is correct |
| Custom errors | Production contracts where gas efficiency matters |
In most new contracts, the pattern is: require for simple preconditions at function entry, custom errors with revert for everything else, and assert sparingly for internal consistency checks.
A Complete Example
This contract combines all four mechanisms in realistic ways:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;
error NotOwner();
error InsufficientFunds(uint available, uint requested);
contract Vault {
address public owner;
mapping(address => uint) public balances;
uint public totalDeposited;
constructor() {
owner = msg.sender;
}
function deposit() public payable {
// require: validates external input (msg.value)
require(msg.value > 0, "Must deposit something");
balances[msg.sender] += msg.value;
totalDeposited += msg.value;
// assert: verifies internal invariant, contract balance must match our accounting
assert(address(this).balance == totalDeposited);
}
function withdraw(uint amount) public {
// custom error with revert: gas-efficient, carries typed context
if (balances[msg.sender] < amount) {
revert InsufficientFunds(balances[msg.sender], amount);
}
balances[msg.sender] -= amount;
totalDeposited -= amount;
(bool success, ) = msg.sender.call{value: amount}("");
// require: verifies result of external call
require(success, "Transfer failed");
assert(address(this).balance == totalDeposited);
}
function emergencyWithdraw() public {
// custom error: cheap, precise
if (msg.sender != owner) {
revert NotOwner();
}
uint contractBalance = address(this).balance;
totalDeposited = 0;
(bool success, ) = owner.call{value: contractBalance}("");
require(success, "Emergency withdrawal failed");
}
}
Notice that assert appears after state changes, not before. That is intentional. assert checks that the state your code just produced is internally consistent. It is not a replacement for require at the start of a function.
One Pattern to Avoid
A common mistake is using assert for input validation:
// Wrong, this is input validation, not an invariant
assert(msg.value > 0);
// Correct
require(msg.value > 0, "Must send ETH");
The practical consequence in pre-0.8.0 contracts: a failed assert would burn all remaining gas. Even in 0.8.0+, using assert for input validation signals to auditors that you either misunderstand the distinction or have a logic error you are trying to catch. Neither is a good look.
Summary
Solidity's four error mechanisms share the same outcome, transaction revert, but communicate different things:
requiresays "the caller did something wrong or the external state is not ready"revertsays "a condition was not met, and I need explicit control over when to stop"assertsays "my contract's internal state is broken, this should never happen"- Custom errors say all of the above, with less gas and better typed data for callers
Use each for what it was designed for. The distinction matters most when someone else is reading your code, or when an auditor is trying to understand what your contract assumes about the world.