Zig Error Handling

Error handling in Zig is simple, explicit, and powerful.
Tuesday, September 9, 2025

Keep it simple

It’s no secret that I love Zig and have been using it a lot lately. One of its special features and quirks is it’s error handling. Zig has a unique approach to error handling that is different from many other programming languages. In this post, I will explain how Zig’s error handling works and why it fits really well into my mental model of programming.

What are Error sets

In Zig errors are represented as a special type called error sets. An error set is basically an enum that can contain multiple error values of possible errors that a function can return. For example, a function that reads a file might return an error set that contains errors for “file not found”, “permission denied”, and “read error”.

Here’s a simple example

const std = @import("std");

const FileError = error{
    FileNotFound,
    PermissionDenied,
    ReadError,
};

// A function that reads a file and returns a byte slice or a FileError
fn readFile(path: []const u8) FileError![]u8 {
    // Example: always return an error for demonstration
    return FileError.FileNotFound;
}

Simple, clear and to the point. The function readFile returns a result type that can either be a byte slice (the file contents) or an error from the FileError set.

But what is that ! in the return type?

The ! symbol in Zig is used to indicate that a function can return an error. Which in this case it does, in fact it’s the only thing it does in the example above.

As long as your enum is defined as an error set it is a valid return type for a function that prefixes it’s return type with !.

Tip: You can also use !void as a return type for functions that don’t return any value but can still fail.

Tip #2: You can merge sets with || if your function deals with multiple error sources. We’ll see that in a sec.

Error Unions

Functions can return a value or error. That’s !T where T is the return type. This is called an error union.

You can use it like so:

fn readNumber(str: []const u8) !u32 {
    if (str.len == 0) return error.EmptyString;
    return std.fmt.parseInt(u32, str, 10);
}

const num = try readNumber("123"); // errors bubble up
const bad = readNumber("abc") catch |err| {
    std.debug.print("Caught: {}\n", .{err});
    return err;
};

In this example, readNumber tries to parse a string into a number. If the string is empty, it returns an EmptyString error. If parsing fails, it returns whatever error std.fmt.parseInt produces.

The try keyword is used to propagate errors up the call stack. If readNumber returns an error, it will be returned from the calling function as well.

No hidden stuff, no exceptions, no magic, just plain and simple error handling.

Note: I personally prefer programming languages which treat errors as values. It makes reasoning about code much easier.

What is Bubbling Up Errors ?

Above we briefly touched on bubbling errors up the call stack with try. This is a common pattern in Zig.

When you use try, if the function returns an error, that error is immediately returned from the calling function. This allows you to propagate errors up the call stack without having to explicitly handle them at each level.

Here’s an example:

fn deepFunction() !void {
    return error.SomethingWentWrong;
}

fn middleFunction() !void {
    try deepFunction();
}

pub fn main() void {
    middleFunction() catch |err| {
        std.debug.print("Error: {}\n", .{err});
    };
}

In this example, deepFunction returns an error. middleFunction calls deepFunction using try, which means if deepFunction returns an error, that error is immediately returned from middleFunction. Finally, in main, we call middleFunction and handle the error using catch.

So in summary: Deep fails -> middle propagates -> main handles.

Simple and straightforward.

What are Error Handling Best Practices in Zig?

Here are some best practices for error handling in Zig:

  1. Use explicit error sets: Define clear and specific error sets for your functions. This makes it easier to understand what errors can occur and how to handle them.
  2. Name errors descriptively: Use descriptive names for your errors to make it clear what went wrong.
  3. Handle all cases: When using catch, make sure to handle all possible error cases. This ensures that your program can gracefully recover from errors.
  4. Propagate instead of ignoring: Use try to propagate errors up the call stack instead of ignoring them. This helps to ensure that errors are not silently ignored and can be handled appropriately.
  5. Use defer for cleanup: Use defer to ensure that resources are cleaned up properly, even in the presence of errors.

Now these are just some of the guidelines that I adhere to yours may differ and that’s all right.

Real World Example

Let’s look at a more complex example that combines multiple error sources and demonstrates error propagation.

const std = @import("std");

const ProcessError = error{
    FileNotFound,
    InvalidFormat,
    AccessDenied,
} || std.fs.File.OpenError || std.fs.File.ReadError;

fn processDocument(path: []const u8) ProcessError!void {
    const file = try std.fs.cwd().openFile(path, .{});
    defer file.close();

    var buffer: [1024]u8 = undefined;
    const size = try file.readAll(&buffer);

    if (size < 10) return ProcessError.InvalidFormat;

    std.debug.print("Processed {} bytes from {}\n", .{size, path});
}

pub fn main() void {
    const files = [_][]const u8{"doc1.txt", "doc2.txt"};
    for (files) |file| {
        processDocument(file) catch |err| {
            switch(err) {
                ProcessError.FileNotFound => continue,
                ProcessError.InvalidFormat => continue,
                ProcessError.AccessDenied => continue,
                else => continue,
            }
        };
    }
}

In this example, processDocument attempts to open and read a file. It can return

  • FileNotFound if the file doesn’t exist,
  • InvalidFormat if the file content is not as expected,
  • AccessDenied if there are permission issues,
  • or any errors from std.fs.File.OpenError and std.fs.File.ReadError. In main, we attempt to process multiple documents, handling errors appropriately based on their type.

We use try to propagate errors from file operations and custom checks, and catch in main to handle them. After we use defer to ensure the file is closed properly, even if an error occurs during reading.

Conclusion

Zig’s error handling model is simple, explicit, and powerful. By treating errors as values and using constructs like try and catch, Zig allows developers to write clear and maintainable code that gracefully handles errors. Whether you’re writing low-level system code or high-level application logic, Zig’s error handling model provides the tools you need to manage errors effectively.

I hope this post has given you a good understanding of how error handling works in Zig and why it’s a great fit for many programming scenarios.

Wanna show support?

If you find my sporadic thoughts and ramblings helpful.
You can buy me a coffee if you feel like it.
It's not necessary but it's always appreciated. The content will always stay free regardless.