Designing Efficient Error Handling in Vyper Contracts with Custom Errors
Learn how to implement Vyper custom errors for gas‑efficient, clear failure handling. This guide covers requirements, minimal design, trust boundaries, operational checks, failure modes, and when to rethink the approach.
13 Jul 2026, 03:15 UTC

Problem & Takeaway
When a Vyper contract fails, the default require or revert with a string message can cost excessive gas and produce opaque error data. Custom errors, introduced in Vyper 0.3.3, allow developers to emit compact, typed failure data that is both gas‑efficient and easily decoded by off‑chain tools. This article shows how to design a contract that uses custom errors, why it matters, and when you might need to revert to string messages.
Requirements
- Explicit failure semantics for every public function.
- Minimal gas usage for revert paths.
- Deterministic error decoding by web3 clients and monitoring services.
- Compatibility with Vyper 0.3.3+ (no older releases).
Smallest Suitable Design
The core pattern is simple:
- Define each error as a Vyper
classinheriting fromExceptionat the top of the file. - Use
raisewith the error class inside functions instead ofrequireorrevert("msg"). - Expose the error signatures via the ABI; external callers can decode the revert data automatically.
Example contract:
# @version 0.3.3
# --- Custom Errors --------------------------------------------------
class InsufficientBalance(Exception):
# Parameters are emitted in order
pass
class Unauthorized(Exception):
pass
# --- State -----------------------------------------------------------
owner: public(address)
balances: public(HashMap[address, uint256])
# --- Constructor ---------------------------------------------------
@external
def __init__():
self.owner = msg.sender
# --- Core Logic -----------------------------------------------------
@external
def withdraw(amount: uint256) -> None:
"""Withdraws amount from the caller’s balance."""
# Owner check – string revert is fine for a single check
if msg.sender != self.owner:
raise Unauthorized()
# Balance check – custom error to save gas
if self.balances[msg.sender] < amount:
raise InsufficientBalance(self.balances[msg.sender], amount)
self.balances[msg.sender] -= amount
send(msg.sender, amount)
Notice the use of raise with an error class. The compiler emits the error’s selector (first 4 bytes of keccak256 of the signature) followed by ABI‑encoded arguments.
Trust & Data Boundaries
Errors are part of the contract’s public interface. They are defined inside the contract or a shared library, not exposed to untrusted parties. Once deployed, the error signatures are immutable and cannot be altered by external users. The only data that flows to the caller is the revert payload, which is deterministic and fully controlled by the contract author.
Operational Checks
Testing and monitoring are essential to guarantee that each error path behaves as expected.
Unit Tests (Foundry)
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/MyContract.vy";
contract TestMyContract is Test {
MyContract c;
function setUp() public {
c = new MyContract();
// Fund the owner
vm.prank(address(1));
c.deposit{value: 1 ether}();
}
function testInsufficientBalance() public {
vm.expectRevert(abi.encodeWithSelector(MyContract.InsufficientBalance.selector, 1 ether, 2 ether));
c.withdraw(2 ether);
}
}
Run with:
forge test --match-path src/MyContract.vy
Check gas savings by comparing the revert cost:
gasWithCustom = vm.prank(address(1)); c.withdraw(2 ether); // revert
gasWithString = vm.prank(address(1)); c.revertWithString(); // custom function that reverts with string
assert(gasWithCustom < gasWithString);
Runtime Monitoring
Deploy to a local testnet or a public RPC and capture transaction logs:
npx hardhat node
npx hardhat run scripts/deploy.js --network localhost
# Trigger error
npx hardhat run scripts/trigger-error.js --network localhost
# Inspect revert data
npx hardhat console --network localhost
> const receipt = await ethers.provider.getTransactionReceipt(txHash);
> console.log(receipt.status); // 0 for revert
> console.log(receipt.revertReason); // decoded by ethers automatically
Ensure that the revertReason matches the expected error type.
Failure Modes
- Incorrect error reference: Using
raise UnknownError()whereUnknownErroris not defined will cause a compile‑time error. - Missing error definition: If a function uses
raise CustomError()but the class is omitted, compilation fails. - String revert fallback: Accidentally using
revert("msg")inside a function that should use a custom error inflates gas and hides structured data. - Tooling that does not parse Vyper custom errors may display the raw selector, confusing developers.
When to Redesign
Switch to string reverts if any of the following applies:
- The contract must interoperate with a library or framework that only understands string revert messages.
- The deployment target uses a Vyper version <0.3.3, which does not support custom errors.
- The project prioritizes maximum compatibility over gas savings, and the added bytecode size of error definitions is a concern.
In those cases, revert to require(condition, "Error message") or revert("Error message") and document the semantics in the contract’s README.
Practical Verification Checklist
- Compile with
vyper --versionand confirm it is ≥0.3.3. - Run
vyper -f bin MyContract.vyand verify that the bytecode contains the error selectors (first 4 bytes of keccak). - Deploy to a local chain and trigger each error path; use
eth_getTransactionReceiptto confirm revert data. - Use
ethers.jsorweb3.pyto decode the error and assert the message matches the expected type. - Measure gas cost of a revert with a custom error vs a string revert; ensure the custom path is cheaper.
Adhering to this design yields contracts that are both gas‑efficient and transparent for off‑chain consumers, while keeping failure semantics explicit and deterministic.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.