Custom Errors in Solidity: A Gas Optimization Technique
Custom errors in Solidity provide a gas-efficient alternative to traditional require statements with string messages. They reduce deployment and runtime costs by storing error signatures instead of full string data on the blockchain.
Published on 20 October, 2025
Understanding Gas Costs in Error Handling
When you write smart contracts, every byte of data costs gas. Traditional error handling using require statements with string messages has been the standard approach for years, but it comes with a significant overhead. Each character in an error string gets stored in the contract bytecode and costs gas during deployment. At runtime, reverting with a string message also consumes gas to encode and return that string.
Let me show you the traditional approach:
contract OldWayErrors { address public owner; constructor() { owner = msg.sender; } function restrictedFunction() public view { require(msg.sender == owner, "Only owner can call this function"); } }
Every time this contract deploys, that error string "Only owner can call this function" gets embedded in the bytecode. The longer your error messages, the more expensive your deployment becomes.
How Custom Errors Work
Solidity 0.8.4 introduced custom errors as a new way to handle failures. Instead of storing full strings, custom errors use a function signature (4 bytes) to identify the error type. This is the same mechanism Solidity uses for function selectors.
Here is the modern approach:
contract NewWayErrors { address public owner; error Unauthorized(); constructor() { owner = msg.sender; } function restrictedFunction() public view { if (msg.sender != owner) { revert Unauthorized(); } } }
When you declare
error Unauthorized(), Solidity creates a 4-byte selector by hashing the error signature. During execution, only these 4 bytes get returned instead of a full string. The difference in gas consumption is substantial.Gas Savings Breakdown
The savings come from two places: deployment cost and runtime cost.
For deployment, consider a contract with multiple require statements:
contract TraditionalErrors { mapping(address => uint256) public balances; function transfer(address to, uint256 amount) public { require(to != address(0), "Cannot transfer to zero address"); require(balances[msg.sender] >= amount, "Insufficient balance"); require(amount > 0, "Amount must be greater than zero"); balances[msg.sender] -= amount; balances[to] += amount; } }
Those three error strings add up. Each character is stored in the contract bytecode. If you have 10 functions with similar checks, you are paying for hundreds of characters.
Now compare with custom errors:
contract ModernErrors { mapping(address => uint256) public balances; error ZeroAddress(); error InsufficientBalance(); error InvalidAmount(); function transfer(address to, uint256 amount) public { if (to == address(0)) revert ZeroAddress(); if (balances[msg.sender] < amount) revert InsufficientBalance(); if (amount == 0) revert InvalidAmount(); balances[msg.sender] -= amount; balances[to] += amount; } }
Each error declaration adds minimal bytecode. The error selector (4 bytes) is all that gets encoded. You can reuse these errors across multiple functions without duplicating string data.
At runtime, the gas savings continue. When a transaction reverts, returning 4 bytes costs less than encoding and returning a full string. The exact savings depend on string length, but you typically save between 20 to 50 gas per revert for runtime execution.
Custom Errors with Parameters
Custom errors become even more powerful when you add parameters. You can include contextual information without the gas overhead of string formatting:
contract AdvancedErrors { mapping(address => uint256) public balances; error InsufficientBalance(uint256 available, uint256 required); error TransferFailed(address from, address to, uint256 amount); function transfer(address to, uint256 amount) public { uint256 balance = balances[msg.sender]; if (balance < amount) { revert InsufficientBalance(balance, amount); } balances[msg.sender] -= amount; balances[to] += amount; } }
This approach gives you rich error information for debugging and user interfaces. The parameters get ABI-encoded and returned, but the base cost remains lower than string concatenation in traditional error messages.
Real World Impact
Let me walk through actual measurements. Deploy a contract with 10 require statements using string messages, then deploy an equivalent using custom errors. The deployment cost difference can range from 5,000 to 15,000 gas depending on message lengths. For a contract deployed once but called thousands of times, the runtime savings multiply quickly.
Consider a DeFi protocol processing millions of transactions. If 10% of transactions revert (failed transfers, unauthorized access, etc.), saving 30 gas per revert translates to significant cost reductions across the protocol lifetime.
Best Practices for Implementation
When converting existing contracts, start by identifying your most common error cases. Replace frequently-used require statements first to maximize savings. Group related errors logically:
contract TokenWithErrors { // Access control errors error Unauthorized(); error NotOwner(); // Transfer errors error ZeroAddress(); error InsufficientBalance(uint256 available, uint256 required); error TransferToSelf(); // State errors error ContractPaused(); error AlreadyInitialized(); // ... implementation }
Keep error names descriptive but concise. Tools like Etherscan automatically decode custom errors, so users still get meaningful information. You lose nothing in terms of clarity while gaining efficiency.
For complex validation, you might combine checks:
function complexOperation(address target, uint256 value) public { if (target == address(0)) revert ZeroAddress(); if (msg.sender != owner) revert Unauthorized(); if (paused) revert ContractPaused(); if (value > maxValue) revert InvalidAmount(); // operation logic }
The if-revert pattern reads cleanly and costs less than require statements. Some developers prefer this style for consistency once they adopt custom errors throughout their codebase.
Compatibility Considerations
Custom errors require Solidity 0.8.4 or later. If you maintain contracts that need to compile with earlier versions, you cannot use this feature. Most modern projects have moved beyond 0.8.4, making custom errors widely accessible.
External tools and libraries need to understand custom errors for proper decoding. Major frameworks like Hardhat, Foundry, and Ethers.js all support custom error decoding. Block explorers parse them correctly. The ecosystem has caught up.
When writing tests, catch custom errors by their signature:
// Using Foundry's vm.expectRevert function testUnauthorizedAccess() public { vm.expectRevert(Unauthorized.selector); contract.restrictedFunction(); }
This ensures your error handling works as intended while maintaining the gas benefits.
Conclusion
Custom errors represent a straightforward optimization that every Solidity developer should adopt. The gas savings are measurable and meaningful, especially for frequently-called functions or contracts handling high transaction volumes. By replacing require statements with custom errors, you reduce both deployment and runtime costs without sacrificing code clarity or debugging capability. The pattern has become standard practice in modern Solidity development, supported across the entire toolchain. Start converting your contracts today to take advantage of these built-in efficiency gains.