Zig Error Handling
Zig treats errors as values — first-class results that functions return alongside their normal output. Unlike exceptions in Java or Python, errors in Zig do not unwind the call stack invisibly. Every error is explicit in the type signature of the function that produces it, and every call site must acknowledge the error before using the result.
Defining Errors
const FileError = error{
NotFound,
PermissionDenied,
DiskFull,
};
An error set is a named collection of error values. Each name inside the set is a unique error. Error names follow the same conventions as enum values — capitalized and descriptive.
Returning Errors from Functions
fn readAge(raw: []const u8) !u8 {
if (raw.len == 0) return error.Empty;
const age = std.fmt.parseInt(u8, raw, 10)
catch return error.InvalidFormat;
if (age > 150) return error.OutOfRange;
return age;
}
Return type: !u8
┌─────┬────────────────────────────────────────┐
│ ! │ This function can fail │
│ u8 │ On success, returns an unsigned 8-bit │
│ │ integer │
└─────┴────────────────────────────────────────┘
Possible outcomes:
readAge("25") → 25 (success)
readAge("") → error.Empty
readAge("abc") → error.InvalidFormat
readAge("200") → error.OutOfRange
Handling Errors at the Call Site
try — Propagate the Error Up
pub fn main() !void {
const age = try readAge("28");
std.debug.print("Age: {d}\n", .{age});
}
try runs the function. If it succeeds, age gets the value. If it fails, try returns the error from the current function immediately. The error travels up the call stack until something handles it or main prints it and exits.
readAge("28") succeeds → age = 28 → continue
readAge("") fails → try returns error.Empty from main
→ Zig prints error trace and exits
catch — Handle the Error Here
const age = readAge("abc") catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
readAge("abc") → error.InvalidFormat
|
catch captures err = error.InvalidFormat
|
print "Error: error.InvalidFormat"
return (exit the function)
catch with a Default Value
const age = readAge("xyz") catch 0;
// If parsing fails, use 0 as the age
std.debug.print("Age: {d}\n", .{age});
catch unreachable — Crash on Error (Use Carefully)
const age = readAge("30") catch unreachable;
// Programmer asserts this CANNOT fail
// If it does fail in debug mode → panic
Error Return Traces
Zig records where an error originated and how it traveled through the call stack. In debug builds, when a program exits due to an unhandled error, it prints this trace:
error: InvalidFormat [trace]: → readAge (input.zig:4) → processInput (input.zig:20) → main (input.zig:35)
This trace shows every function that passed the error up with try. Finding the source of an error takes seconds rather than hours of debugging.
anyerror — Accept Any Error
fn doSomething() anyerror!void {
// Can return any error from any error set
}
anyerror is the global error union — it can hold any error from any set in your program. Use it sparingly, for generic utilities that call many different functions. Specific error sets are better because they document exactly what can go wrong.
Error Sets — Merging and Subsets
const NetworkError = error{ Timeout, Disconnected };
const ParseError = error{ InvalidJson, MissingField };
// Merge with ||
const AppError = NetworkError || ParseError;
// AppError contains: Timeout, Disconnected, InvalidJson, MissingField
Inferring Error Sets
When a function uses ! without specifying which error set, Zig infers the set from what the function body can actually return:
fn process(x: i32) !i32 {
if (x < 0) return error.Negative;
if (x > 1000) return error.TooLarge;
return x * 2;
}
// Zig infers error set: { Negative, TooLarge }
switch on Errors
const result = readAge(user_input);
if (result) |age| {
std.debug.print("Valid age: {d}\n", .{age});
} else |err| switch (err) {
error.Empty => std.debug.print("Please enter an age.\n", .{}),
error.InvalidFormat => std.debug.print("Age must be a number.\n", .{}),
error.OutOfRange => std.debug.print("Age must be 0–150.\n", .{}),
}
result = error.InvalidFormat
|
else |err| → err = error.InvalidFormat
|
switch → matches error.InvalidFormat
|
print "Age must be a number."
Errors vs Optionals — The Decision
Scenario Best Choice ──────────────────────────────── ────────────── Search returns no match ?T (optional) File open fails (OS error) !T (error) User skipped an optional field ?T (optional) Network request times out !T (error) HashMap key not present ?T (optional) JSON parse fails !T (error)
Practical Example: Configuration File Reader
const std = @import("std");
const ConfigError = error{
FileMissing,
Malformed,
KeyNotFound,
};
fn getValue(config: []const u8, key: []const u8) ConfigError![]const u8 {
if (config.len == 0) return error.FileMissing;
if (!std.mem.startsWith(u8, config, key)) return error.KeyNotFound;
const idx = std.mem.indexOfScalar(u8, config, '=')
orelse return error.Malformed;
return config[idx + 1 ..];
}
pub fn main() void {
const config = "host=localhost";
const host = getValue(config, "host") catch |err| blk: {
std.debug.print("Config error: {}\n", .{err});
break :blk "127.0.0.1";
};
std.debug.print("Connecting to: {s}\n", .{host});
}
Output:
Connecting to: localhost
