Skip to content

Propagating Errors

Propagating an error means passing it to the caller instead of handling it immediately.

Propagating an error means passing it to the caller instead of handling it immediately.

In Zig, this is normal. Many functions are not the right place to decide what an error means. A low-level function may know that opening a file failed, but it may not know whether the program should retry, create a default file, print a warning, or stop.

So the low-level function returns the error upward.

The Basic Pattern

The most common propagation tool is try.

fn loadConfig() !void {
    try readConfigFile();
    try parseConfig();
    try validateConfig();
}

Read this as:

read the config file;
if that fails, return the error;

parse the config;
if that fails, return the error;

validate the config;
if that fails, return the error;

If all three steps succeed, loadConfig succeeds.

If any step fails, loadConfig stops and returns that error to its caller.

Why Propagation Is Useful

Imagine this call chain:

main
  runApp
    loadConfig
      readConfigFile

If readConfigFile fails because the file is missing, should it print a message and exit?

Usually, no.

The file-reading function should only report what happened. It should not decide the whole program’s policy.

fn readConfigFile() ![]u8 {
    return try std.fs.cwd().readFileAlloc(
        allocator,
        "config.json",
        1024 * 1024,
    );
}

The higher-level function can decide what to do.

fn runApp() !void {
    const config_text = readConfigFile() catch |err| switch (err) {
        error.FileNotFound => {
            try createDefaultConfig();
            return;
        },
        else => return err,
    };

    try parseAndUseConfig(config_text);
}

Here, runApp handles one specific error and propagates the rest.

Propagation Keeps Layers Clean

A good Zig program often has layers.

Low-level code does concrete work.

High-level code makes policy decisions.

For example:

file module:
  open, read, write

parser module:
  tokenize, parse, validate

app module:
  decide what to do when something fails

The file module should not know the application’s user interface.

The parser module should not know whether errors should be printed, logged, or shown in a GUI.

So these modules usually propagate errors.

fn parseConfig(text: []const u8) !Config {
    const tokens = try tokenize(text);
    const tree = try parseTokens(tokens);
    return try buildConfig(tree);
}

Each step can fail. parseConfig does not handle every failure itself. It returns the failure to its caller.

Propagating with try

This line:

const value = try operation();

is shorthand for:

const value = operation() catch |err| return err;

So try means:

if this failed, return the same error from my function

The error travels upward one function at a time.

fn inner() !void {
    return error.Failed;
}

fn middle() !void {
    try inner();
}

fn outer() !void {
    try middle();
}

If inner returns error.Failed, then middle returns error.Failed, then outer returns error.Failed.

No exception is thrown. No hidden stack unwinding happens. Each function returns an error value normally.

Propagation Requires Compatible Error Sets

If you propagate an error, your function’s error set must allow that error.

This works:

const ReadError = error{
    FileNotFound,
    PermissionDenied,
};

const LoadError = error{
    FileNotFound,
    PermissionDenied,
    InvalidFormat,
};

fn readFile() ReadError![]const u8 {
    return error.FileNotFound;
}

fn loadConfig() LoadError!Config {
    const text = try readFile();
    return try parseConfig(text);
}

readFile may return FileNotFound or PermissionDenied.

loadConfig may also return those errors, so propagation is valid.

If the outer error set does not include the inner error, Zig rejects the code.

const ReadError = error{
    PermissionDenied,
};

const LoadError = error{
    InvalidFormat,
};

fn readFile() ReadError![]const u8 {
    return error.PermissionDenied;
}

fn loadConfig() LoadError!Config {
    const text = try readFile(); // not allowed
    return try parseConfig(text);
}

loadConfig cannot promise only InvalidFormat while returning PermissionDenied.

The type must tell the truth.

Widening the Error Set

One way to fix incompatible propagation is to widen the outer error set.

const LoadError = error{
    PermissionDenied,
    InvalidFormat,
};

Now loadConfig can propagate PermissionDenied.

This is the simplest fix when the lower-level error should be visible to callers.

Mapping Errors

Sometimes you do not want to expose lower-level errors.

In that case, use catch to map one error into another.

const LoadError = error{
    CannotReadConfig,
    InvalidFormat,
};

fn loadConfig() LoadError!Config {
    const text = readFile() catch |err| switch (err) {
        error.FileNotFound => return error.CannotReadConfig,
        error.PermissionDenied => return error.CannotReadConfig,
    };

    return parseConfig(text) catch |err| switch (err) {
        error.BadSyntax => return error.InvalidFormat,
        error.MissingField => return error.InvalidFormat,
    };
}

Now callers of loadConfig see a cleaner API:

CannotReadConfig
InvalidFormat

They do not need to know every detail from the file and parser layers.

This is not always better. It depends on the API. Sometimes callers need precise errors. Sometimes they need a simple category.

Partial Handling

A common pattern is to handle one error and propagate the rest.

const file = std.fs.cwd().openFile("config.json", .{}) catch |err| switch (err) {
    error.FileNotFound => {
        try createDefaultConfig();
        return;
    },
    else => return err,
};

This code handles FileNotFound.

For all other errors, it propagates the original error with:

else => return err

This is useful when one error has a clear local answer, but the rest should still go upward.

Propagation and Cleanup

When an error is propagated with try, cleanup still matters.

fn processFile() !void {
    const file = try std.fs.cwd().openFile("data.txt", .{});
    defer file.close();

    try readHeader(file);
    try readBody(file);
    try validateFile(file);
}

If readBody(file) fails, the function returns early.

Before it returns, this cleanup runs:

defer file.close();

This is why try and defer are often used together.

For resources that should be cleaned only on failure, use errdefer.

fn createBuffer(allocator: std.mem.Allocator) ![]u8 {
    const buffer = try allocator.alloc(u8, 4096);
    errdefer allocator.free(buffer);

    try initializeBuffer(buffer);

    return buffer;
}

If initializeBuffer fails, buffer is freed.

If the function succeeds, the buffer is returned to the caller.

Do Not Propagate Everything Blindly

Propagation is useful, but not every error should travel forever.

At some point, the program needs a policy.

For example:

pub fn main() !void {
    try runApp();
}

This is acceptable for small programs. If runApp fails, the error reaches main.

For a real command-line program, you may want better user-facing behavior:

pub fn main() void {
    runApp() catch |err| {
        std.debug.print("error: {}\n", .{err});
        std.process.exit(1);
    };
}

Here, main is the policy boundary. It turns an internal error into a message and an exit code.

Where Errors Should Stop

A useful rule is:

propagate errors through mechanical layers;
handle errors at decision layers

Mechanical layers do work:

read file
parse text
allocate memory
send request
decode response

Decision layers know context:

show message to user
retry operation
use default config
abort startup
skip one bad record

Good error handling usually means errors travel upward until they reach code that has enough context to make a useful decision.

The Core Idea

Propagating an error means returning it to your caller.

In Zig, you usually do this with try.

const value = try operation();

This means:

use the value if operation succeeds;
return the error if operation fails

Propagation keeps low-level code simple and honest. Higher-level code can decide what the failure means.