
Solidity Error Handling Patterns for Lower Gas and Better UX
Why error handling matters for performance
Every failed transaction still consumes gas up to the point of failure. If your contract performs expensive work before discovering an invalid condition, users pay for computation that should have been avoided. Good error handling is therefore partly about failing early.
There are two main performance angles:
- Runtime cost: how much gas is spent when a check fails or succeeds.
- Deployment cost: how much bytecode is added by error strings, repeated checks, and helper logic.
A well-designed error strategy improves both. It also makes contracts easier to integrate with because off-chain systems can decode failures more reliably.
The main Solidity error mechanisms
Solidity provides several ways to signal failure:
require(condition, "message")revert("message")revert CustomError(...)assert(condition)
Each has a different purpose.
| Mechanism | Typical use | Gas characteristics | Notes |
|---|---|---|---|
require | Validate user input or external state | Moderate | String messages increase bytecode size |
revert | Abort execution explicitly | Similar to require | Often paired with custom errors |
CustomError | Domain-specific failure conditions | Cheapest for rich errors | Best for production contracts |
assert | Internal invariants that should never fail | Not for user errors | Indicates a bug, not bad input |
For performance-sensitive contracts, the most important shift is moving from revert strings to custom errors.
Prefer custom errors over revert strings
Revert strings are convenient during development, but they are expensive. The string literal is embedded in bytecode, increasing deployment size. When the revert happens, the string data also needs to be returned, which adds runtime overhead.
Custom errors are more compact and more expressive for production use.
Example: revert string vs custom error
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Vault {
error InsufficientBalance(uint256 available, uint256 required);
mapping(address => uint256) private balances;
function withdraw(uint256 amount) external {
uint256 available = balances[msg.sender];
if (available < amount) {
revert InsufficientBalance(available, amount);
}
unchecked {
balances[msg.sender] = available - amount;
}
}
}This pattern is better than:
require(balances[msg.sender] >= amount, "Insufficient balance");The custom error version gives you:
- lower deployment bytecode
- structured data for frontends and indexers
- cheaper revert payloads
- easier debugging in tooling that decodes ABI errors
Best practices for custom errors
- Put them near the top of the contract or in a shared interface.
- Use typed fields instead of generic strings.
- Keep error names short but descriptive.
- Include only data that is useful for debugging or UI messaging.
A good error should answer: what failed, and what values caused it?
Use require for simple validation, but keep it lean
require is still useful when the condition is simple and the message is temporary or development-only. However, in production code, it should be used sparingly.
Good uses of require
- validating function arguments
- checking permissions
- guarding against zero addresses
- enforcing preconditions before state changes
Avoid these patterns
- long revert strings
- repeated
requirechecks with similar messages - validating the same condition multiple times in one call path
If you need a descriptive failure in production, prefer a custom error.
Example: access control check
error NotOwner(address caller);
modifier onlyOwner() {
if (msg.sender != owner) {
revert NotOwner(msg.sender);
}
_;
}This is more efficient than:
require(msg.sender == owner, "Only owner can call this function");The custom error is smaller and gives the caller address directly.
Fail early before expensive operations
A common performance mistake is doing work before verifying that the call is valid. If a transaction is going to fail, it should fail as soon as possible.
Example: validate before looping
function distribute(address[] calldata recipients, uint256 amount) external {
if (recipients.length == 0) {
revert EmptyRecipients();
}
if (amount == 0) {
revert ZeroAmount();
}
for (uint256 i = 0; i < recipients.length; ++i) {
_transfer(recipients[i], amount);
}
}This is better than checking inside the loop. If the input is invalid, the function exits before any iteration or external work.
Why this matters
Early failure reduces wasted gas in two ways:
- it avoids executing expensive logic
- it avoids partially processing state before discovering an invalid condition
This is especially important in functions that:
- iterate over arrays
- perform multiple storage writes
- make external calls
- compute hashes or signatures
Separate user errors from internal invariants
Not every failure should be handled the same way. Solidity distinguishes between:
- user-facing validation failures: bad input, unauthorized access, insufficient balance
- internal invariants: impossible states that indicate a bug
Use custom errors or require for user-facing failures. Use assert only for invariants that should never be violated if the contract is correct.
Example: invariant check
function _mint(address to, uint256 amount) internal {
uint256 supplyBefore = totalSupply;
totalSupply += amount;
balances[to] += amount;
assert(totalSupply == supplyBefore + amount);
}If this assertion fails, the contract logic is broken. That is different from a user passing invalid input. Do not use assert for normal validation because it communicates the wrong intent and can make debugging harder.
Design errors for frontends and integrations
A good error strategy is not just about gas. It also improves the developer experience for wallets, dashboards, and automation scripts.
Custom errors are ABI-encoded, which means off-chain code can decode them reliably. This is useful when a frontend wants to display a specific message or when a bot needs to decide whether to retry.
Recommended error design
- Use one error per failure category.
- Include relevant context fields.
- Keep error semantics stable across versions.
- Avoid overloading one error with too many meanings.
Example error set
error Unauthorized(address caller);
error InvalidDeadline(uint256 deadline);
error SlippageExceeded(uint256 expected, uint256 actual);
error OrderExpired(uint256 timestamp, uint256 deadline);This is much easier to consume than a generic "execution failed" message.
Choose the right validation order
The order of checks affects gas usage. Put the cheapest and most likely-to-fail checks first.
Practical ordering strategy
- Check simple scalar conditions first.
- Check permissions next.
- Check state-dependent conditions.
- Perform expensive calculations last.
- Make external calls only after all local validation passes.
Example
function placeOrder(uint256 amount, uint256 deadline) external {
if (amount == 0) revert ZeroAmount();
if (block.timestamp > deadline) revert OrderExpired(block.timestamp, deadline);
if (!isWhitelisted[msg.sender]) revert Unauthorized(msg.sender);
uint256 fee = _quoteFee(amount);
if (fee > maxFee) revert FeeTooHigh(fee, maxFee);
_recordOrder(msg.sender, amount, fee);
}This sequence avoids computing the fee if the call is already invalid due to amount, deadline, or authorization.
Reduce duplicate checks with internal helpers
Repeated validation logic can bloat bytecode and make maintenance harder. A small internal helper can centralize checks, but only if it does not introduce unnecessary abstraction overhead.
Good pattern: shared internal validation
function _ensureNonZero(uint256 value) internal pure {
if (value == 0) revert ZeroValue();
}Use it when the same rule appears in multiple functions. This keeps behavior consistent and reduces the chance of divergent error handling.
Be careful with over-abstraction
Do not create deeply nested helper chains for simple checks. Solidity inlining is not guaranteed in the way developers might expect, and too much indirection can make code harder to audit. Use helpers for shared policy, not for every tiny condition.
Handle external call failures deliberately
When a contract calls another contract, failure can happen for many reasons: revert, out-of-gas, bad return data, or unexpected behavior. A performance-conscious contract should not wrap every external call in heavy logic unless necessary.
Recommendations
- Validate local conditions before external calls.
- Keep external call sequences short.
- Avoid unnecessary
try/catchunless you need to recover from failure. - If you do use
try/catch, decode only the error information you actually need.
Example: minimal recovery path
try oracle.getPrice() returns (uint256 price) {
if (price == 0) revert InvalidPrice();
_updatePrice(price);
} catch {
revert OracleUnavailable();
}This pattern is useful when the contract can distinguish between a recoverable external failure and a valid response. Do not use try/catch as a default wrapper around every call; it adds complexity and can obscure the main execution path.
Compare common error patterns
| Pattern | Best for | Pros | Cons |
|---|---|---|---|
| Revert string | Prototyping, temporary debugging | Easy to read | Higher deployment cost |
| Custom error | Production validation | Cheap, structured, ABI-friendly | Requires tooling support |
assert | Invariants | Clear bug signal | Not for user input |
try/catch | External call recovery | Controlled fallback | More code, more branching |
In production contracts, custom errors are usually the best default.
A practical checklist for production contracts
Use this checklist when reviewing error handling for performance:
- Replace revert strings with custom errors where possible.
- Check cheap conditions before expensive work.
- Validate inputs before loops and external calls.
- Use
assertonly for internal invariants. - Keep error types specific and stable.
- Avoid redundant validation across nested functions.
- Decode external failures only when the caller can act on them.
- Make sure frontends know how to interpret custom errors.
Putting it all together
A well-optimized Solidity contract does not just compute efficiently; it also fails efficiently. That means rejecting invalid calls early, returning structured errors, and avoiding unnecessary bytecode growth from verbose messages.
The most practical improvement for most teams is to replace revert strings with custom errors and then review the order of validation checks. These two changes often produce immediate gains in deployment size, runtime gas, and integration quality.
If you are building a protocol, marketplace, or vault, treat error handling as part of your performance budget. The best error is the one that prevents wasted execution before it starts.
