Zig: Error Handling

Home · Blog

5 March 2025

One of Zig’s big improvements over C is that it has good support for error handling built into the language. The basic idea is simple: you define error values using the error keyword, and functions that can fail explicitly return an error value instead of their normal return type.

Defining and returning errors

You can define a set of errors with the error keyword:

const MathError = error {
    DivisionByZero,
    IntegerOverflow,
};

and use the ! operator to indicate that a function may return those errors:

fn divide(a: i8, b: i8) MathError!i8 {
    if (b == 0) return MathError.DivisionByZero;
    if (a == -128 and b == -1) return MathError.IntegerOverflow;
    return @divTrunc(a, b);
}

MathError!i8 is called an error union. We could also write the function like this (bold code is what’s changed):

fn divide(a: i8, b: i8) !i8 {
    if (b == 0) return error.DivisionByZero;
    if (a == -128 and b == -1) return error.IntegerOverflow;
    return @divTrunc(a, b);
}

With !i8 as the return type, the compiler will infer the error union by collecting all error values used in the function. The error.DivisionByZero shorthand creates an error value without first defining an error set.

You might be wondering now: what is an error value, really? In the source code, an error is identified only by its name. That means you can define the same error multiple times. In the following code, e1, e2, and e3 are all identical:

const MyError = error{ FileNotFound, PermissionDenied };
const e1 = MyError.FileNotFound;

const YourError = error{ FileNotFound, FilenameInvalid };
const e2 = YourError.FileNotFound;

const e3 = error.FileNotFound;

When the compiler builds your program, it collects all error values and assigns each a number. At runtime, the error values are just integers.

Handling errors

You can use an if to distinguish successful return from the error case:

if (divide(x, y)) |n| {
    std.debug.print("{} / {} = {}\n", .{x, y, n});
} else |err| {
    // handle error
}

A similar syntax exists for while loops:

while (f()) |value| {
    // use value
} else |err| {
    // handle error
}

When f returns an error, the else branch is evaluated and the loop ends.

It’s more common to use the catch and try keywords, though. You can use catch to substitute a default value:

const n = divide(x, y) catch 0;
// the type of n is i8; it'll be 0 in case of error

or run some code if there was an error:

const n = divide(x, y) catch {
    std.process.exit(1);
};

The try keyword is for the common case where you just want to return errors to your caller:

const n = try divide(x, y);

is equivalent to

const n = divide(x, y) catch |err| return err;

If you’re sure won’t be an error you can assert that with unreachable:

const n = divide(4, 2) catch unreachable;

If there is an error after all, this will cause a panic (when compiled with Debug and ReleaseSafe) or undefined behavior (with ReleaseFast and ReleaseSmall). Incidentally, that’s also what happens if we evaluate @divTrunc(a, b) without checking the numbers and there’s actually a division by zero or an integer overflow.

Returning errors from main

If you return an error from the main function, the program will print the error and exit with a non-zero exit status. For example, this code

pub fn main() !void {
    std.debug.print("{}\n", .{try divide(-128, -1)});
}

will print

error: IntegerOverflow

In Debug mode, it also prints a trace of where the error came from:

errors$ zig build-exe -O Debug overflow.zig
errors$ ./overflow
error: IntegerOverflow
/home/levin/code/experiment/zig/errors/overflow.zig:5:32: 0x1034e33 in divide (overflow)
    if (a == -128 and b == -1) return error.IntegerOverflow;
                               ^
/home/levin/code/experiment/zig/errors/overflow.zig:10:31: 0x1035049 in main (overflow)
    std.debug.print("{}\n", .{try divide(-128, -1)});
                              ^