Zig Error Handling

Zig treats errors as values — first-class results that functions return alongside their normal output. Unlike exceptions in Java or Python, errors in Zig do not unwind the call stack invisibly. Every error is explicit in the type signature of the function that produces it, and every call site must acknowledge the error before using the result.

Defining Errors

const FileError = error{
    NotFound,
    PermissionDenied,
    DiskFull,
};

An error set is a named collection of error values. Each name inside the set is a unique error. Error names follow the same conventions as enum values — capitalized and descriptive.

Returning Errors from Functions

fn readAge(raw: []const u8) !u8 {
    if (raw.len == 0) return error.Empty;
    const age = std.fmt.parseInt(u8, raw, 10)
        catch return error.InvalidFormat;
    if (age > 150) return error.OutOfRange;
    return age;
}
  Return type: !u8
  ┌─────┬────────────────────────────────────────┐
  │ !   │ This function can fail                 │
  │ u8  │ On success, returns an unsigned 8-bit  │
  │     │ integer                                │
  └─────┴────────────────────────────────────────┘

  Possible outcomes:
  readAge("25")  → 25  (success)
  readAge("")    → error.Empty
  readAge("abc") → error.InvalidFormat
  readAge("200") → error.OutOfRange

Handling Errors at the Call Site

try — Propagate the Error Up

pub fn main() !void {
    const age = try readAge("28");
    std.debug.print("Age: {d}\n", .{age});
}

try runs the function. If it succeeds, age gets the value. If it fails, try returns the error from the current function immediately. The error travels up the call stack until something handles it or main prints it and exits.

  readAge("28") succeeds → age = 28 → continue
  readAge("")   fails    → try returns error.Empty from main
                           → Zig prints error trace and exits

catch — Handle the Error Here

const age = readAge("abc") catch |err| {
    std.debug.print("Error: {}\n", .{err});
    return;
};
  readAge("abc") → error.InvalidFormat
       |
  catch captures err = error.InvalidFormat
       |
  print "Error: error.InvalidFormat"
  return (exit the function)

catch with a Default Value

const age = readAge("xyz") catch 0;
// If parsing fails, use 0 as the age
std.debug.print("Age: {d}\n", .{age});

catch unreachable — Crash on Error (Use Carefully)

const age = readAge("30") catch unreachable;
// Programmer asserts this CANNOT fail
// If it does fail in debug mode → panic

Error Return Traces

Zig records where an error originated and how it traveled through the call stack. In debug builds, when a program exits due to an unhandled error, it prints this trace:

  error: InvalidFormat
  [trace]:
  → readAge (input.zig:4)
  → processInput (input.zig:20)
  → main (input.zig:35)

This trace shows every function that passed the error up with try. Finding the source of an error takes seconds rather than hours of debugging.

anyerror — Accept Any Error

fn doSomething() anyerror!void {
    // Can return any error from any error set
}

anyerror is the global error union — it can hold any error from any set in your program. Use it sparingly, for generic utilities that call many different functions. Specific error sets are better because they document exactly what can go wrong.

Error Sets — Merging and Subsets

const NetworkError = error{ Timeout, Disconnected };
const ParseError   = error{ InvalidJson, MissingField };

// Merge with ||
const AppError = NetworkError || ParseError;
// AppError contains: Timeout, Disconnected, InvalidJson, MissingField

Inferring Error Sets

When a function uses ! without specifying which error set, Zig infers the set from what the function body can actually return:

fn process(x: i32) !i32 {
    if (x < 0) return error.Negative;
    if (x > 1000) return error.TooLarge;
    return x * 2;
}
// Zig infers error set: { Negative, TooLarge }

switch on Errors

const result = readAge(user_input);

if (result) |age| {
    std.debug.print("Valid age: {d}\n", .{age});
} else |err| switch (err) {
    error.Empty         => std.debug.print("Please enter an age.\n", .{}),
    error.InvalidFormat => std.debug.print("Age must be a number.\n", .{}),
    error.OutOfRange    => std.debug.print("Age must be 0–150.\n",    .{}),
}
  result = error.InvalidFormat
       |
  else |err| → err = error.InvalidFormat
       |
  switch → matches error.InvalidFormat
       |
  print "Age must be a number."

Errors vs Optionals — The Decision

  Scenario                         Best Choice
  ──────────────────────────────── ──────────────
  Search returns no match          ?T (optional)
  File open fails (OS error)       !T (error)
  User skipped an optional field   ?T (optional)
  Network request times out        !T (error)
  HashMap key not present          ?T (optional)
  JSON parse fails                 !T (error)

Practical Example: Configuration File Reader

const std = @import("std");

const ConfigError = error{
    FileMissing,
    Malformed,
    KeyNotFound,
};

fn getValue(config: []const u8, key: []const u8) ConfigError![]const u8 {
    if (config.len == 0) return error.FileMissing;
    if (!std.mem.startsWith(u8, config, key)) return error.KeyNotFound;
    const idx = std.mem.indexOfScalar(u8, config, '=')
        orelse return error.Malformed;
    return config[idx + 1 ..];
}

pub fn main() void {
    const config = "host=localhost";

    const host = getValue(config, "host") catch |err| blk: {
        std.debug.print("Config error: {}\n", .{err});
        break :blk "127.0.0.1";
    };

    std.debug.print("Connecting to: {s}\n", .{host});
}

Output:

Connecting to: localhost

Leave a Comment

Your email address will not be published. Required fields are marked *