Skip to editor content
learningzig.orglesson 6 of 25

Error Handling

Zig's error handling is one of its most distinctive features. Instead of exceptions, Zig uses error unions and error sets that integrate directly into the type system, giving you explicit, composable, and zero-cost error handling.

Error Unions

An error union combines a normal return type with a possible error. The syntax is ErrorSet!ReturnType.

const std = @import("std");

fn divide(numerator: f64, denominator: f64) !f64 {
    if (denominator == 0.0) {
        return error.DivisionByZero;
    }
    return numerator / denominator;
}

pub fn main() !void {
    const result1 = divide(10.0, 3.0) catch |err| {
        std.debug.print("Error: {}\n", .{err});
        return;
    };
    std.debug.print("10 / 3 = {d:.4}\n", .{result1});

    _ = divide(5.0, 0.0) catch |err| {
        std.debug.print("Caught error: {}\n", .{err});
        return;
    };
}

Custom Error Sets

Define your own error sets to describe exactly what can go wrong.

const std = @import("std");

const ParseError = error{
    InvalidCharacter,
    Overflow,
    EmptyInput,
};

fn parseAge(input: []const u8) ParseError!u8 {
    if (input.len == 0) return ParseError.EmptyInput;

    var result: u16 = 0;
    for (input) |c| {
        if (c < '0' or c > '9') return ParseError.InvalidCharacter;
        result = result * 10 + (c - '0');
        if (result > 255) return ParseError.Overflow;
    }
    return @intCast(result);
}

pub fn main() !void {
    const age = parseAge("25") catch |err| {
        std.debug.print("Parse failed: {}\n", .{err});
        return;
    };
    std.debug.print("Parsed age: {}\n", .{age});

    _ = parseAge("abc") catch |err| {
        std.debug.print("Expected error for 'abc': {}\n", .{err});
        return;
    };
}

The try Keyword

try is syntactic sugar for propagating errors upward automatically.

const std = @import("std");

const MathError = error{
    DivisionByZero,
    NegativeSquareRoot,
};

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

fn safeSqrt(x: f64) MathError!f64 {
    if (x < 0.0) return MathError.NegativeSquareRoot;
    return @sqrt(x);
}

fn calculateFormula(a: f64, b: f64, c: f64) MathError!f64 {
    const ratio = try safeDivide(a, b);
    const root = try safeSqrt(ratio);
    return root + c;
}

pub fn main() !void {
    const result = calculateFormula(16.0, 4.0, 1.0) catch |err| {
        std.debug.print("Calculation failed: {}\n", .{err});
        return;
    };
    std.debug.print("Result: {d:.2}\n", .{result});

    _ = calculateFormula(10.0, 0.0, 1.0) catch |err| {
        std.debug.print("Expected error: {}\n", .{err});
        return;
    };
}

The errdefer Keyword

errdefer runs cleanup only when the function returns an error.

const std = @import("std");

const Config = struct {
    name: []const u8,
    value: i32,
};

fn processConfig(valid: bool) !Config {
    std.debug.print("Step 1: Allocating resources...\n", .{});

    errdefer {
        std.debug.print("Cleanup: Rolling back due to error!\n", .{});
    }

    std.debug.print("Step 2: Validating...\n", .{});
    if (!valid) {
        return error.InvalidConfig;
    }

    std.debug.print("Step 3: Success!\n", .{});
    return Config{ .name = "production", .value = 42 };
}

pub fn main() !void {
    std.debug.print("--- Success case ---\n", .{});
    const config = processConfig(true) catch |err| {
        std.debug.print("Failed: {}\n", .{err});
        return;
    };
    std.debug.print("Config: {s} = {}\n\n", .{ config.name, config.value });

    std.debug.print("--- Error case ---\n", .{});
    _ = processConfig(false) catch |err| {
        std.debug.print("Failed: {}\n", .{err});
        return;
    };
}

Error Merging and Switching

Merge error sets with || and switch on specific errors.

const std = @import("std");

const ReadError = error{ EndOfStream, Timeout };
const ParseError = error{ InvalidFormat, Overflow };
const ProcessError = ReadError || ParseError;

fn processData(stage: u8) ProcessError!i32 {
    return switch (stage) {
        0 => error.EndOfStream,
        1 => error.Timeout,
        2 => error.InvalidFormat,
        3 => error.Overflow,
        else => @as(i32, 42),
    };
}

pub fn main() !void {
    for (0..6) |i| {
        const stage: u8 = @intCast(i);
        const result = processData(stage) catch |err| {
            switch (err) {
                error.EndOfStream => std.debug.print("Stage {}: End of data\n", .{stage}),
                error.Timeout => std.debug.print("Stage {}: Timed out\n", .{stage}),
                error.InvalidFormat => std.debug.print("Stage {}: Bad format\n", .{stage}),
                error.Overflow => std.debug.print("Stage {}: Value too large\n", .{stage}),
            }
            continue;
        };
        std.debug.print("Stage {}: Got value {}\n", .{ stage, result });
    }
}

Try It Yourself

const std = @import("std");

const ValidationError = error{
    NameTooShort,
    NameTooLong,
    InvalidAge,
    InvalidEmail,
};

fn validateName(name: []const u8) ValidationError!void {
    if (name.len < 2) return ValidationError.NameTooShort;
    if (name.len > 50) return ValidationError.NameTooLong;
}

fn validateAge(age: i32) ValidationError!void {
    if (age < 0 or age > 150) return ValidationError.InvalidAge;
}

fn validateEmail(email: []const u8) ValidationError!void {
    var has_at = false;
    for (email) |c| {
        if (c == '@') has_at = true;
    }
    if (!has_at) return ValidationError.InvalidEmail;
}

fn validateUser(name: []const u8, age: i32, email: []const u8) ValidationError!void {
    try validateName(name);
    try validateAge(age);
    try validateEmail(email);
}

pub fn main() !void {
    validateUser("Alice", 30, "alice@example.com") catch |err| {
        std.debug.print("Validation failed: {}\n", .{err});
        return;
    };
    std.debug.print("User is valid!\n", .{});

    validateUser("A", 30, "alice@example.com") catch |err| {
        std.debug.print("Short name: {}\n", .{err});
    };

    validateUser("Bob", 200, "bob@mail.com") catch |err| {
        std.debug.print("Invalid age: {}\n", .{err});
    };

    validateUser("Charlie", 25, "no-at-sign") catch |err| {
        std.debug.print("Bad email: {}\n", .{err});
    };
}

Key Takeaways

  • Error unions (!T) combine error information with return values in the type system
  • Custom error sets document exactly what can go wrong in a function
  • try propagates errors upward automatically, keeping code clean
  • catch lets you handle errors at the call site with full control
  • errdefer ensures cleanup runs only on error paths
  • Error sets can be merged with || and switched on for granular handling
  • Zig's error handling has zero runtime overhead compared to exceptions

Pro Tip: Prefer specific error sets over the inferred ! when writing library code. Explicit error sets like ParseError!u8 serve as documentation and help callers know exactly which errors to handle. Use the inferred ! for application code where convenience matters more.

Next Steps

Now that you can handle errors gracefully, it's time to understand the memory that your programs allocate and free -- and why Zig makes you manage it explicitly.

Next lesson

Memory and Allocators

Understand Zig memory management — compare stack vs heap allocation, use allocator interfaces, and avoid leaks with explicit manual memory control.

28 min