What Is Error Handling in Zig
Zig approaches error handling without exceptions, hidden allocations, or stack unwinding. Instead, errors are values that flow through normal control paths using the ! (error union) type, explicit try, catch, and user-defined error sets. This design makes every error path visible, predictable, and zero-cost by default.
Error Union Type
The core building block is the error union type: ErrorSet!T. It means "either an error from ErrorSet or a value of type T". For example, !u32 can be any error or a u32. When a function can fail, its return type uses an error union.
// A function that returns an error or a string
fn parseInteger(text: []const u8) !i64 {
// implementation...
}
// A function that returns a specific error set or void
fn allocateBuffer(size: usize) error{OutOfMemory}!void {
// implementation...
}
The error set itself is a list of named error values like error.OutOfMemory or error.InvalidCharacter. Zig infers error sets automatically when you use return error.SomeError;. Explicit error sets in function signatures document the possible failures and allow the compiler to verify exhaustiveness.
Why Explicit Errors Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →- Zero-cost – errors are just integers returned alongside the result; no exceptions or tables.
- No hidden allocations – no stack unwinding or exception objects that require dynamic memory.
- All paths visible – every caller must either handle the error or propagate it explicitly, leaving no surprise crashes.
- Compile-time verification – error set inference ensures you never forget to handle a documented error case.
- Lightweight composition – error sets merge automatically when a function returns multiple error types.
Basic Usage Patterns
Propagating Errors with try
try evaluates an expression that produces an error union. If the result is an error, it returns that error immediately from the enclosing function. If the result is a value, it unwraps the payload. This is the simplest way to bubble errors upward.
fn readConfig(path: []const u8) !Config {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 4096);
defer allocator.free(content);
return parseConfig(content);
}
Here, each try either unwraps a valid file handle, buffer, or config, or immediately returns the underlying error. The defer blocks ensure cleanup regardless of the error path.
Handling Errors with catch
catch provides a fallback value or action when the left-hand side yields an error. It’s useful for providing defaults, fallback logic, or custom error handling without leaving the function.
const file = std.fs.cwd().openFile("data.txt", .{}) catch |err| {
if (err == error.FileNotFound) {
std.debug.print("File missing, creating new one...\n", .{});
return createDefaultFile();
}
return err; // propagate other errors
};
You can also use a simple default value:
const value = parseInteger("123") catch 0;
Capturing the Error with if and switch
Instead of catch, you can use an if statement that checks the error union explicitly:
if (parseInteger("abc")) |num| {
// success: num is i64
std.debug.print("Parsed: {d}\n", .{num});
} else |err| {
// error: err is an error value
std.debug.print("Failed: {}\n", .{err});
}
For exhaustive handling of multiple errors, switch on the error value is idiomatic:
const result = parseInteger("abc");
const num = switch (result) {
.value => |v| v,
.err => |e| switch (e) {
error.InvalidCharacter => {
// fallback
return 0;
},
error.Overflow => {
std.debug.print("Overflow, clamping...\n", .{});
return i64.max;
},
else => |other| return other, // propagate unexpected errors
},
};
Defining and Inferring Error Sets
Implicit Error Sets
If you omit an explicit error set and just use !, Zig infers the set from all return error.X statements in the function body. This is convenient for internal functions.
fn doSomething() !void {
if (someCondition) return error.Failed;
try mayAlsoFail(); // error set merges with mayAlsoFail's errors
}
The inferred set becomes the union of all error values returned directly or via try from called functions. This keeps signatures short but still type-safe.
Explicit Error Sets
For public API functions, always specify the exact error set to document what callers must handle:
pub fn parseJson(text: []const u8) error{InvalidSyntax, MissingValue}!ParsedJson {
// ...
}
Explicit sets are also required when you want to restrict the errors a function can return (e.g., wrapping a lower-level function and translating errors).
Error Set Merging with ||
When you combine multiple error sets in a return type, Zig automatically merges them. You can also use || to explicitly form a union:
fn readAndParse(path: []const u8) (std.fs.File.OpenError || error{InvalidFormat})!Data {
const file = try std.fs.cwd().openFile(path, .{});
// ...
}
This pattern keeps error sets precise and avoids the catch-all anyerror where possible.
Common Patterns in Practice
Bubbling Up Errors
Most Zig functions propagate errors to their caller using try. This creates a clean, linear error path up the call stack without manual checks at every level.
fn initializeApp() !void {
const config = try loadConfig(); // propagate loadConfig errors
const state = try createState(config); // propagate createState errors
try startServer(state); // propagate startServer errors
}
Translating Errors
Sometimes you want to convert a low-level error into a higher-level one to hide implementation details:
fn readUserData(id: u32) error{NotFound, DatabaseFailure}!User {
const raw = db.fetch(id) catch |err| switch (err) {
error.RowMissing => return error.NotFound,
else => return error.DatabaseFailure,
};
return parseUser(raw) catch error.DatabaseFailure;
}
Handling Expected Failures
Some operations have well-known failure modes that aren’t truly exceptional. For example, reading a file that might not exist:
fn maybeReadConfig(path: []const u8) !?Config {
const file = std.fs.cwd().openFile(path, .{}) catch |err| {
if (err == error.FileNotFound) return null;
return err; // other I/O errors are still failures
};
defer file.close();
const content = try file.readToEndAlloc(allocator, 4096);
defer allocator.free(content);
return parseConfig(content);
}
Using orelse for Alternative Logic
orelse works like catch but allows chaining entire expressions that return error unions. It’s useful for trying a series of fallback strategies:
fn readNumber(source: anytype) !i32 {
return source.readInt(i32) orelse source.readFloat(i32) orelse error.CouldNotRead;
}
Each orelse evaluates the next expression if the previous one returned an error, propagating only if all fail.
Best Practices
- Don’t discard errors silently – always handle or propagate; Zig warns on unused error unions.
- Prefer
tryfor simple propagation – it keeps code linear and readable. - Use explicit error sets in public interfaces – they serve as documentation and enable exhaustive handling.
- Avoid
anyerrorunless absolutely necessary – it disables compile-time exhaustiveness checks and weakens type safety. - Leverage
deferfor resource cleanup – it works seamlessly withtryand error returns. - Translate errors at module boundaries – keep low-level details from leaking into higher-level logic.
- Use
catchwith a block for non-trivial fallback – logging, default creation, or error transformation. - Test error paths – Zig’s
testing.expectErrorlets you assert specific errors are returned.
Testing Error Handling
Zig’s test runner makes it trivial to verify error behavior:
test "parseInteger returns InvalidCharacter for bad input" {
const result = parseInteger("abc");
try std.testing.expectError(error.InvalidCharacter, result);
}
test "parseInteger returns valid number" {
const result = try parseInteger("42");
try std.testing.expectEqual(@as(i64, 42), result);
}
Conclusion
Zig’s error handling model gives you the performance and clarity of explicit error values without the boilerplate of traditional status-code checking. By combining error unions, try, catch, and inferred error sets, you can write robust software where failure paths are as carefully considered as success paths. The language’s compile-time checks ensure you never accidentally ignore an error, and the lack of exceptions keeps overhead zero. Adopting these patterns consistently leads to codebases that are easier to audit, debug, and maintain—exactly what systems programming demands.