Skip to main content

Error Sets

Zig uses explicit error sets instead of exceptions:
const FileOpenError = error{
    AccessDenied,
    OutOfMemory,
    FileNotFound,
};
Error sets are types that can be merged and used with error unions.

Error Unions

Error unions combine a normal type with an error set using !:
const std = @import("std");
const maxInt = std.math.maxInt;

pub fn parseU64(buf: []const u8, radix: u8) !u64 {
    var x: u64 = 0;

    for (buf) |c| {
        const digit = charToDigit(c);

        if (digit >= radix) {
            return error.InvalidChar;
        }

        // x *= radix
        var ov = @mulWithOverflow(x, radix);
        if (ov[1] != 0) return error.OverFlow;

        // x += digit
        ov = @addWithOverflow(ov[0], digit);
        if (ov[1] != 0) return error.OverFlow;
        x = ov[0];
    }

    return x;
}

fn charToDigit(c: u8) u8 {
    return switch (c) {
        '0'...'9' => c - '0',
        'A'...'Z' => c - 'A' + 10,
        'a'...'z' => c - 'a' + 10,
        else => maxInt(u8),
    };
}

test "parse u64" {
    const result = try parseU64("1234", 10);
    try std.testing.expect(result == 1234);
}
The ! in a return type means the function returns an error union. !T is shorthand for anyerror!T.

Catching Errors

Using catch

The catch keyword provides a default value when an error occurs:
const parseU64 = @import("error_union_parsing_u64.zig").parseU64;

fn doAThing(str: []u8) void {
    const number = parseU64(str, 10) catch 13;
    _ = number; // ...
}

Using catch with Error Capture

fn handleError(str: []const u8) void {
    const number = parseU64(str, 10) catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    _ = number;
}
const value = riskyFunction() catch 0;

Using try

The try keyword propagates errors to the caller:
fn wrapper() !u64 {
    const result = try parseU64("1234", 10);
    return result * 2;
}
try is equivalent to:
const result = parseU64("1234", 10) catch |err| return err;
Functions using try must have a return type that is an error union.

If with Error Unions

You can test and unwrap error unions with if:
test "if error union" {
    const a: anyerror!u32 = 0;
    if (a) |value| {
        try expect(value == 0);
    } else |err| {
        _ = err;
        unreachable;
    }

    const b: anyerror!u32 = error.BadValue;
    if (b) |value| {
        _ = value;
        unreachable;
    } else |err| {
        try expect(err == error.BadValue);
    }

    // Access the value by reference using a pointer capture.
    var c: anyerror!u32 = 3;
    if (c) |*value| {
        value.* = 9;
    } else |_| {
        unreachable;
    }
}

Defer

The defer keyword executes code when leaving the current scope:
const std = @import("std");
const print = std.debug.print;

pub fn main() void {
    print("\n", .{});

    defer {
        print("1 ", .{});
    }
    defer {
        print("2 ", .{});
    }
    if (false) {
        // defers are not run if they are never executed.
        defer {
            print("3 ", .{});
        }
    }
}
// Output: 2 1
Defer statements execute in reverse order (LIFO - Last In, First Out).

Errdefer

The errdefer keyword executes code only when returning an error:
const std = @import("std");

fn captureError(captured: *?anyerror) !void {
    errdefer |err| {
        captured.* = err;
    }
    return error.GeneralFailure;
}

test "errdefer capture" {
    var captured: ?anyerror = null;

    if (captureError(&captured)) unreachable else |err| {
        try std.testing.expectEqual(error.GeneralFailure, captured.?);
        try std.testing.expectEqual(error.GeneralFailure, err);
    }
}

Practical Errdefer Example

fn createResource() !*Resource {
    const resource = try allocator.create(Resource);
    errdefer allocator.destroy(resource);
    
    try resource.init();
    errdefer resource.deinit();
    
    try resource.configure();
    
    return resource;
}
errdefer is perfect for cleanup when initialization fails partway through.

Error Return Traces

Zig can provide error return traces in Debug and ReleaseSafe modes:
pub fn main() !void {
    try riskyOperation();
}

fn riskyOperation() !void {
    return error.Failed;
}
When run, this shows where the error originated and propagated through.
fn operation() !void {
    return error.Failed;
}

Error Set Coercion

Error sets can be coerced to supersets:
const FileError = error{ NotFound, AccessDenied };
const AllErrors = error{ NotFound, AccessDenied, OutOfMemory };

fn getFileError() FileError {
    return error.NotFound;
}

test "error coercion" {
    const err: AllErrors = getFileError(); // OK - subset coerces to superset
    try expect(err == error.NotFound);
}

Switch on Errors

You can switch on error values:
fn handleError(err: anyerror) void {
    switch (err) {
        error.FileNotFound => std.log.err("File not found", .{}),
        error.AccessDenied => std.log.err("Access denied", .{}),
        else => std.log.err("Unknown error", .{}),
    }
}

Best Practices

  • Use try to propagate errors up the call stack
  • Use catch when you can handle the error locally
  • Use errdefer for cleanup on error paths
  • Use defer for cleanup on all exit paths
  • Prefer specific error sets over anyerror for better documentation
  • Use error return traces to debug error propagation
Avoiding catch unreachable unless you’re absolutely certain the error cannot occur. It will panic if the error does occur.

Build docs developers (and LLMs) love