
Understanding Solidity Require, Revert, and Assert for Safe Error Handling
Why error handling matters in Solidity
A failed transaction does more than return an error message. It also rolls back all state changes made during execution. This is essential for preserving contract correctness, but it also means your error strategy affects:
- user experience in wallets and dApps
- gas usage during failed calls
- auditability and maintainability
- how clearly integrators can diagnose problems
In practice, Solidity developers use checks to enforce assumptions such as:
- the caller is allowed to perform an action
- input values are within valid ranges
- a contract has enough balance or token allowance
- internal invariants still hold after state transitions
The key is choosing the right mechanism for the right kind of failure.
The three error primitives
Solidity provides three commonly used ways to stop execution:
| Primitive | Typical use | Gas behavior | Best for |
|---|---|---|---|
require | Validate inputs and external conditions | Refunds remaining gas after failure | User errors, preconditions |
revert | Abort with custom logic or message | Refunds remaining gas after failure | Complex validation, explicit failure paths |
assert | Check internal invariants | Consumes more gas on failure semantics | Bugs, impossible states |
Although they all halt execution, they communicate different intent.
require
Use require for conditions that must be true before the function can proceed. These are usually checks on user input, permissions, balances, or contract state.
Example:
require(amount > 0, "Amount must be greater than zero");
require(msg.sender == owner, "Only owner can call this function");require is the most common choice for validating external conditions.
revert
Use revert when you want to stop execution explicitly, often inside conditional branches or helper functions. It is especially useful when the failure path depends on more complex logic.
Example:
if (balance < amount) {
revert("Insufficient balance");
}In modern Solidity, revert is also the foundation for custom errors, which are more gas-efficient than string messages.
assert
Use assert only for invariants that should never fail if the contract is correct. If an assert fails, it usually indicates a bug in your code, not bad user input.
Example:
assert(totalSupply >= balances[user]);If you are checking something that can fail because of user behavior or external state, do not use assert.
A practical example: a simple vault
The following contract demonstrates all three patterns in a realistic setting. It accepts deposits, allows withdrawals, and enforces internal consistency.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Vault {
address public owner;
mapping(address => uint256) public deposits;
uint256 public totalDeposits;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
require(msg.value > 0, "Deposit must be positive");
deposits[msg.sender] += msg.value;
totalDeposits += msg.value;
assert(address(this).balance >= totalDeposits);
}
function withdraw(uint256 amount) external {
require(amount > 0, "Amount must be positive");
require(deposits[msg.sender] >= amount, "Insufficient deposited balance");
deposits[msg.sender] -= amount;
totalDeposits -= amount;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
assert(address(this).balance == totalDeposits);
}
function emergencyWithdraw(uint256 amount) external {
if (msg.sender != owner) {
revert("Only owner can perform emergency withdrawal");
}
require(amount <= address(this).balance, "Not enough ether in vault");
(bool sent, ) = owner.call{value: amount}("");
require(sent, "Emergency transfer failed");
}
}What this example shows
requirevalidates deposits and withdrawal preconditions.reverthandles the owner-only branch in a readable way.assertchecks that the vault’s accounting matches the contract balance.
This pattern makes the contract easier to reason about because each failure type has a clear purpose.
Choosing between require, revert, and assert
A useful rule of thumb is to ask: “Who is responsible for this failure?”
- If the caller can fix the issue, use
requireorrevert. - If the contract logic should never allow the issue, use
assert.
Decision guide
| Situation | Recommended primitive | Example |
|---|---|---|
| Invalid user input | require | require(amount > 0) |
| Unauthorized caller | require | require(msg.sender == owner) |
| Branch-specific failure | revert | if (...) revert("...") |
| Unreachable internal state | assert | assert(counter <= limit) |
| External call failed | require | require(success, "Call failed") |
This distinction is important during audits. A contract that uses assert for user input suggests a misunderstanding of failure semantics.
Custom errors: the modern alternative to string messages
String revert messages are easy to read, but they are expensive because the string data is stored in bytecode. Solidity supports custom errors, which are much more gas-efficient and easier to scale in larger contracts.
Defining and using custom errors
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract TokenVault {
error ZeroAmount();
error InsufficientBalance(uint256 available, uint256 required);
error NotOwner(address caller);
address public owner;
mapping(address => uint256) public balances;
constructor() {
owner = msg.sender;
}
function deposit() external payable {
if (msg.value == 0) revert ZeroAmount();
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
uint256 available = balances[msg.sender];
if (amount == 0) revert ZeroAmount();
if (available < amount) revert InsufficientBalance(available, amount);
balances[msg.sender] = available - amount;
(bool ok, ) = msg.sender.call{value: amount}("");
require(ok, "ETH transfer failed");
}
function adminSweep(address payable to) external {
if (msg.sender != owner) revert NotOwner(msg.sender);
(bool ok, ) = to.call{value: address(this).balance}("");
require(ok, "Sweep failed");
}
}Why custom errors are useful
- lower deployment and runtime cost than long revert strings
- structured data can be decoded by frontends and scripts
- easier to standardize across a large codebase
For production contracts, custom errors are often the preferred choice unless a human-readable string is specifically needed for quick prototyping.
Best practices for error messages and checks
Good error handling is not just about stopping execution. It is also about making failures understandable and minimizing unnecessary cost.
1. Validate early
Place checks at the top of the function before any state changes. This reduces wasted gas and makes the control flow obvious.
function mint(uint256 amount) external {
require(amount > 0, "Amount must be positive");
require(totalSupply + amount <= cap, "Cap exceeded");
totalSupply += amount;
}2. Use specific messages or errors
Avoid vague messages like "Invalid input" unless the failure is truly generic. Better diagnostics save time during integration and support.
Prefer:
"Amount must be positive""Caller is not the owner"InsufficientBalance(available, required)
3. Reserve assert for invariants
Do not use assert to validate user-controlled values. If a user can trigger the failure, it is not an invariant.
Good invariant examples:
- array length matches tracked count
- total shares equal the sum of balances
- a state machine remains in a valid phase
4. Keep checks close to the logic they protect
If a function has multiple branches, validate each branch as near as possible to the relevant operation. This improves readability and reduces accidental misuse.
5. Avoid redundant checks
If a value is already guaranteed by a previous condition, do not check it again unless the code path has changed. Over-checking makes contracts harder to read and can increase gas costs.
Common pitfalls
Using assert for access control
This is incorrect:
assert(msg.sender == owner);If the caller is not the owner, that is a normal failure, not an impossible state. Use require or revert instead.
Writing checks after state changes
This can be dangerous because a failing check will revert the whole transaction, but it still makes the code harder to audit. Always validate before mutating state unless the logic specifically requires a post-condition check.
Relying on revert strings for program logic
Frontends should not depend on exact string text for critical behavior. If your app needs machine-readable failure reasons, use custom errors and decode them explicitly.
Forgetting that external calls can fail
Any call to another contract or an address may fail. Always handle the return value or use a pattern that safely propagates failure.
(bool ok, ) = recipient.call{value: amount}("");
require(ok, "Payment failed");Error handling in real projects
In production Solidity code, error handling usually appears in a few recurring places:
- token transfers and allowance checks
- owner/admin-only functions
- parameter validation for minting, staking, or borrowing
- accounting invariants in DeFi protocols
- pause or emergency-stop logic
A well-designed contract often combines all three primitives:
requirefor caller-facing constraintsrevertfor explicit branching failures and custom errorsassertfor internal consistency checks
This combination makes the contract easier to test and audit because each failure type has a clear meaning.
A concise checklist
Before deploying a contract, review your error handling with this checklist:
- Are all user inputs validated before state changes?
- Are permission checks using
requireorrevert, notassert? - Are internal invariants protected with
assertwhere appropriate? - Are custom errors used for frequently triggered failures?
- Do revert reasons help integrators understand what went wrong?
- Are external calls checked for success?
If you can answer yes to these questions, your contract is likely much safer and easier to maintain.
Summary
Solidity gives you three distinct tools for stopping execution, and each serves a different purpose. Use require for expected precondition failures, revert for explicit branching and custom errors, and assert for internal invariants that should never break. The most robust contracts validate early, fail clearly, and separate user mistakes from true bugs.
Error handling is one of the simplest ways to improve contract quality. It reduces ambiguity, supports better tooling, and helps ensure that your smart contract behaves predictably under real-world conditions.
