Skip to editor content
learningzig.orglesson 24 of 25

Error Handling Patterns

Zig treats errors as values, not exceptions. There is no runtime stack unwinding, no hidden control flow, and no try-catch blocks that swallow failures silently. Every function that can fail declares it in its return type, and every call site must explicitly decide what to do with an error. This explicitness is not bureaucracy — it is a design contract that forces you to think about failure before it happens in production.

Once you move past the basics of try and !, Zig's error system reveals a set of composable patterns for writing code that is both robust and readable. Custom error sets give you domain-specific failure modes. errdefer gives you deterministic cleanup on failure paths. Merging error sets lets you compose functions without losing information. Switching on specific errors lets you recover selectively rather than treating all failures the same way.

What You Already Know

Error Handling established the foundation this lesson builds on: error unions (E!T), custom error sets defined with error{ ... }, try for automatic propagation, catch for handling at the call site, errdefer for error-path cleanup, and merging sets with ||. If any of those feel shaky, revisit lesson 06 first — this lesson assumes them and does not re-derive them.

What changes here is the how and the when. The basics tell you that try and catch exist; the patterns below tell you which to reach for, how to recover selectively, how to roll back partial work without leaking, how to compose failure domains across functions, and how to translate one module's error set into another's at a boundary. These are the decisions that separate error handling that merely compiles from error handling that survives production.

try vs catch: Choosing Your Error Path

try expr is shorthand for expr catch |err| return err. It propagates the error upward and exits the current function immediately. This is the right choice when the current function cannot meaningfully recover from a failure and wants to let the caller decide.

catch with a handler gives you the ability to recover at the point of failure — return a default, log a warning, attempt a fallback, or transform the error into a different type.

const std = @import("std");

fn riskyOperation(n: u32) !u32 {
    if (n == 0) return error.ZeroInput;
    if (n > 100) return error.InputTooLarge;
    return n * n;
}

pub fn main() void {
    // catch with a default value
    const a = riskyOperation(0) catch 0;
    std.debug.print("0 -> {}\n", .{a});

    // catch with a block that inspects the error
    const b = riskyOperation(200) catch |err| blk: {
        std.debug.print("Caught {s}, clamping to max\n", .{@errorName(err)});
        break :blk 100 * 100;
    };
    std.debug.print("200 -> {}\n", .{b});

    // try in a function that returns !void
    const result = run();
    if (result) |_| {
        std.debug.print("run() succeeded\n", .{});
    } else |err| {
        std.debug.print("run() failed: {s}\n", .{@errorName(err)});
    }
}

fn run() !void {
    const value = try riskyOperation(7);
    std.debug.print("7 squared = {}\n", .{value});
}

Use try by default and reach for catch when you have a genuine recovery strategy at that level. Recovering too eagerly — turning every error into a zero or a null — defeats the purpose of explicit error handling.

errdefer: Cleanup on Failure Paths

defer runs a statement when the current scope exits, regardless of how. errdefer runs only when the scope exits with an error. This makes it the right tool for cleaning up resources that were acquired partway through a function — if everything succeeds, the caller takes ownership; if anything fails, errdefer ensures no leak occurs.

const std = @import("std");

fn buildBuffers(allocator: std.mem.Allocator, count: usize, size: usize) ![][]u8 {
    if (count == 0 or size == 0) return error.InvalidArgs;
    if (size > 4096) return error.SizeTooLarge;

    const outer = try allocator.alloc([]u8, count);
    errdefer allocator.free(outer);

    var initialized: usize = 0;
    errdefer for (outer[0..initialized]) |buf| allocator.free(buf);

    for (0..count) |i| {
        outer[i] = try allocator.alloc(u8, size);
        @memset(outer[i], @intCast(i));
        initialized += 1;
    }

    return outer;
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const buffers = try buildBuffers(allocator, 3, 4);
    defer {
        for (buffers) |buf| allocator.free(buf);
        allocator.free(buffers);
    }

    for (buffers, 0..) |buf, i| {
        std.debug.print("buf[{}]: {} {} {} {}\n", .{ i, buf[0], buf[1], buf[2], buf[3] });
    }

    // This call fails — errdefer inside buildBuffers cleans up
    const bad = buildBuffers(allocator, 2, 8192);
    if (bad) |_| unreachable else |err| {
        std.debug.print("Expected failure: {s}\n", .{@errorName(err)});
    }
}

The key insight is that errdefer tracks how many inner buffers were allocated so it can free exactly those — not more, not fewer. This pattern of initialized: usize as a progress counter is idiomatic Zig for partial-allocation rollback.

Composing Error Sets

When a function calls multiple other functions with different error sets, Zig automatically merges those sets into the inferred return type. You can also merge sets explicitly using the || operator, which produces a new set containing all values from both.

Explicit merging is useful when you want to name the combined set for documentation purposes, or when you need to write a function that accepts a value of the merged type.

const std = @import("std");

const MathError = error{ DivisionByZero, Overflow };
const ValidationError = error{ NegativeInput, InputTooLarge };

// Named merged set
const CalcError = MathError || ValidationError;

fn validate(n: i64) ValidationError!u32 {
    if (n < 0) return ValidationError.NegativeInput;
    if (n > 1_000_000) return ValidationError.InputTooLarge;
    return @intCast(n);
}

fn safeDivide(a: u32, b: u32) MathError!u32 {
    if (b == 0) return MathError.DivisionByZero;
    return a / b;
}

fn compute(a: i64, b: i64) CalcError!u32 {
    const va = try validate(a);
    const vb = try validate(b);
    return try safeDivide(va, vb);
}

pub fn main() void {
    const cases = [_][2]i64{
        .{ 100, 4 },
        .{ 50, 0 },
        .{ -10, 5 },
        .{ 2_000_000, 1 },
    };

    for (cases) |pair| {
        const a = pair[0];
        const b = pair[1];
        if (compute(a, b)) |result| {
            std.debug.print("{} / {} = {}\n", .{ a, b, result });
        } else |err| switch (err) {
            error.DivisionByZero => std.debug.print("{} / {} = cannot divide by zero\n", .{ a, b }),
            error.NegativeInput => std.debug.print("{} / {} = inputs must be positive\n", .{ a, b }),
            error.InputTooLarge => std.debug.print("{} / {} = inputs must be <= 1,000,000\n", .{ a, b }),
            error.Overflow => std.debug.print("{} / {} = arithmetic overflow\n", .{ a, b }),
        }
    }
}

The switch on err inside the else |err| switch (err) pattern is exhaustive — the compiler will reject it if you forget to handle any member of CalcError. This is one of the most powerful safety properties of named error sets.

Translating Errors Across Module Boundaries

Composing error sets works well inside a module, but at a public API boundary you often do not want to leak a dependency's error set to your callers. If your parser returns ParseError and your storage layer returns StorageError, a caller of your higher-level loadRecord function should not have to know — or handle — every failure mode of both layers. Instead you translate the low-level errors into a small, stable set that describes failures in your domain's terms.

The tool for this is catch |err| switch (err) that returns a different error. Each incoming error is mapped to one of your own, and the mapping is exhaustive — if a dependency adds a new error variant, the compiler forces you to decide how to translate it rather than silently passing it through.

const std = @import("std");

// Low-level error sets from two different "modules".
const ParseError = error{ InvalidCharacter, EmptyInput };
const StorageError = error{ NotFound, Corrupted };

// The stable, caller-facing error set for this API. Notice it says nothing
// about parsing or storage internals — it speaks the domain's language.
const LoadError = error{ BadRequest, Unavailable };

fn parseKey(input: []const u8) ParseError!u32 {
    if (input.len == 0) return ParseError.EmptyInput;
    var key: u32 = 0;
    for (input) |c| {
        if (c < '0' or c > '9') return ParseError.InvalidCharacter;
        key = key * 10 + (c - '0');
    }
    return key;
}

fn fetch(key: u32) StorageError![]const u8 {
    return switch (key) {
        1 => "alice",
        2 => "bob",
        7 => error.Corrupted,
        else => error.NotFound,
    };
}

// The boundary: both dependency error sets are translated into LoadError.
// The switches are exhaustive, so adding a variant upstream won't compile
// until it's handled here.
fn loadRecord(raw_key: []const u8) LoadError![]const u8 {
    const key = parseKey(raw_key) catch |err| switch (err) {
        error.EmptyInput, error.InvalidCharacter => return error.BadRequest,
    };

    return fetch(key) catch |err| switch (err) {
        error.NotFound, error.Corrupted => return error.Unavailable,
    };
}

pub fn main() void {
    const requests = [_][]const u8{ "1", "2", "abc", "99", "7" };

    for (requests) |req| {
        if (loadRecord(req)) |name| {
            std.debug.print("'{s}' -> {s}\n", .{ req, name });
        } else |err| switch (err) {
            error.BadRequest => std.debug.print("'{s}' -> rejected (bad request)\n", .{req}),
            error.Unavailable => std.debug.print("'{s}' -> record unavailable\n", .{req}),
        }
    }
}

A caller of loadRecord sees only LoadError — two clear, actionable outcomes — and is fully insulated from how many layers sit underneath. This is the error-handling equivalent of an abstraction boundary: the type signature is a contract, and translating errors at the edge is how you keep that contract stable while the internals evolve.

When you translate, resist the urge to collapse everything into a single generic error. Preserve distinctions the caller can act on (a malformed request is the caller's fault; an unavailable record is not) and collapse only the distinctions they cannot. The right size for a public error set is "every variant the caller might branch on, and no more."

errdefer with a Captured Error

errdefer can capture the error that triggered it, which is useful when cleanup depends on why the function failed — for logging, metrics, or conditional rollback. The captured value has the function's error-set type and is only bound on the error path.

const std = @import("std");

const OpenError = error{ PermissionDenied, TooManyHandles };

fn openHandle(open_handles: *u32, user_id: u32) OpenError!u32 {
    if (user_id == 0) return error.PermissionDenied;
    if (open_handles.* >= 3) return error.TooManyHandles;

    open_handles.* += 1;
    // The handle is "acquired". If a later step fails, errdefer releases it
    // and can inspect which error caused the failure.
    errdefer |err| {
        open_handles.* -= 1;
        std.debug.print("  rollback: released handle after {s}\n", .{@errorName(err)});
    }

    // A second validation step that can still fail after acquisition.
    if (user_id > 100) return error.PermissionDenied;

    return open_handles.*;
}

pub fn main() void {
    var open_handles: u32 = 0;
    const users = [_]u32{ 5, 200, 0 };

    for (users) |uid| {
        if (openHandle(&open_handles, uid)) |handle| {
            std.debug.print("user {}: opened handle #{}\n", .{ uid, handle });
        } else |err| {
            std.debug.print("user {}: failed with {s}\n", .{ uid, @errorName(err) });
        }
    }

    std.debug.print("open handles still held: {}\n", .{open_handles});
}

The errdefer |err| capture only runs for user_id == 200, where the handle was acquired and then a later check failed — exactly the case where rollback matters. For user_id == 0 the function returns before acquiring anything, so the errdefer never registered and nothing is released. This precision — cleaning up only what was actually acquired, and only when it was — is why errdefer is the backbone of leak-free Zig code.

Try It Yourself

Write a small pipeline that reads a list of raw score strings, parses each one, rejects out-of-range values, and computes the average. Use a custom error set, errdefer for any allocations, and selective catch to skip bad entries while accumulating valid ones.

const std = @import("std");

const ScoreError = error{ InvalidFormat, OutOfRange };

fn parseScore(input: []const u8) ScoreError!u8 {
    var n: u32 = 0;
    if (input.len == 0) return ScoreError.InvalidFormat;
    for (input) |c| {
        if (c < '0' or c > '9') return ScoreError.InvalidFormat;
        n = n * 10 + (c - '0');
        if (n > 100) return ScoreError.OutOfRange;
    }
    return @intCast(n);
}

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const raw = [_][]const u8{ "87", "102", "73", "abc", "95", "0", "100" };

    var valid: std.ArrayList(u8) = .empty;
    defer valid.deinit(allocator);

    for (raw) |entry| {
        const score = parseScore(entry) catch |err| {
            std.debug.print("Skipping '{s}': {s}\n", .{ entry, @errorName(err) });
            continue;
        };
        try valid.append(allocator, score);
    }

    if (valid.items.len == 0) {
        std.debug.print("No valid scores.\n", .{});
        return;
    }

    var sum: u32 = 0;
    for (valid.items) |s| sum += s;
    const avg = sum / @as(u32, @intCast(valid.items.len));
    std.debug.print("\nValid scores: {} | Average: {}\n", .{ valid.items.len, avg });
}

Experiment by adding new error cases — what happens if you forget to handle one in an exhaustive switch? Try changing continue to return err and observe how the function's error set changes.

Key Takeaways

  • Error sets are closed types. Defining your own error sets makes failure modes explicit in the API contract and enables exhaustive switch handling.
  • try propagates, catch recovers. Use try by default; reach for catch only when you have a genuine recovery strategy at that call site.
  • errdefer is for partial-allocation rollback. It runs only on error exit, making it the precise tool for cleaning up resources acquired mid-function without leaking memory on success.
  • Error sets compose with ||. You can merge sets explicitly to name combined failure domains, or let Zig infer the union automatically when you try multiple functions.
  • Translate errors at module boundaries. Use catch |err| switch (err) to map a dependency's error set onto your own stable, caller-facing set — preserve distinctions the caller can act on, collapse the rest, and let the exhaustive switch force you to handle new upstream variants.
  • errdefer |err| can capture the failing error. Bind the error on the cleanup path when rollback or logging needs to know why the function failed; it runs only if the resource was actually acquired before the failure.
  • @errorName(err) returns the error as a string. This is useful for logging and debugging without hard-coding string literals.
  • Exhaustive switches catch missing cases at compile time. Switching on a named error set is safer than switching on anyerror because the compiler enforces completeness.

Pro Tip: Avoid using anyerror in public API return types. It erases the error set, preventing callers from writing exhaustive switches and making your API harder to reason about. Prefer inferred or explicit named sets — the compiler will tell you exactly what can go wrong, and so will your documentation.

Next Steps

You've mastered error handling for individual functions and modules. The final lesson brings it all together: packaging your code into reusable modules, managing dependencies, and structuring projects for distribution.

Next lesson

Zig Packaging and Modules — Project Structure Guide

Structure Zig projects with modules — use @import for file-based modules, control visibility with pub, and manage packages with build.zig.zon. Free tutorial with examples.

22 min