← Back to DevBytes

Error Handling Patterns in Zig

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 →

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

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles