Error Handling Patterns
Zig treats errors as values, not exceptions. There is no runtime stack unwinding, no hidden control flow, and no try-catch blocks that swallow failures silently. Every function that can fail declares it in its return type, and every call site must explicitly decide what to do with an error. This explicitness is not bureaucracy — it is a design contract that forces you to think about failure before it happens in production.
Once you move past the basics of try and !, Zig's error system reveals a set of composable patterns for writing code that is both robust and readable. Custom error sets give you domain-specific failure modes. errdefer gives you deterministic cleanup on failure paths. Merging error sets lets you compose functions without losing information. Switching on specific errors lets you recover selectively rather than treating all failures the same way.
What You Already Know
Error Handling established the foundation this lesson builds on: error unions (E!T), custom error sets defined with error{ ... }, try for automatic propagation, catch for handling at the call site, errdefer for error-path cleanup, and merging sets with ||. If any of those feel shaky, revisit that lesson first — this lesson assumes them and does not re-derive them.
By the end of this lesson you will write a fallible operation that retries on transient failure, rolls its partial work back with errdefer when it finally gives up, and reports through an error set composed from two different modules' failure domains. What changes here is the how and the when. The basics tell you that try and catch exist; the patterns below tell you which to reach for, how to recover selectively, how to roll back partial work without leaking, how to compose failure domains across functions, and how to translate one module's error set into another's at a boundary. These are the decisions that separate error handling that merely compiles from error handling that survives production.
This is an optional extension, not a capstone milestone — the record analyzer handles its failures with the plain try and catch that Error Handling already gave you. What this lesson is for is the point where a program stops being able to fail in one way: once failures arrive from several modules at once, you need a way to keep them distinguishable at a boundary instead of flattening them into one anonymous error, and that is the judgment the patterns below are teaching.
try vs catch: Choosing Your Error Path
try expr is shorthand for expr catch |err| return err. It propagates the error upward and exits the current function immediately. This is the right choice when the current function cannot meaningfully recover from a failure and wants to let the caller decide.
catch with a handler gives you the ability to recover at the point of failure — return a default, log a warning, attempt a fallback, or transform the error into a different type.
const std = @import("std");
fn riskyOperation(n: u32) !u32 {
if (n == 0) return error.ZeroInput;
if (n > 100) return error.InputTooLarge;
return n * n;
}
pub fn main() void {
// catch with a default value
const a = riskyOperation(0) catch 0;
std.debug.print("0 -> {}\n", .{a});
// catch with a block that inspects the error
const b = riskyOperation(200) catch |err| blk: {
std.debug.print("Caught {s}, clamping to max\n", .{@errorName(err)});
break :blk 100 * 100;
};
std.debug.print("200 -> {}\n", .{b});
// try in a function that returns !void
const result = run();
if (result) |_| {
std.debug.print("run() succeeded\n", .{});
} else |err| {
std.debug.print("run() failed: {s}\n", .{@errorName(err)});
}
}
fn run() !void {
const value = try riskyOperation(7);
std.debug.print("7 squared = {}\n", .{value});
}
Use try by default and reach for catch when you have a genuine recovery strategy at that level. Recovering too eagerly — turning every error into a zero or a null — defeats the purpose of explicit error handling.
errdefer: Cleanup on Failure Paths
defer runs a statement when the current scope exits, regardless of how. errdefer runs only when the scope exits with an error. This makes it the right tool for cleaning up resources that were acquired partway through a function — if everything succeeds, the caller takes ownership; if anything fails, errdefer ensures no leak occurs.
const std = @import("std");
fn buildBuffers(allocator: std.mem.Allocator, count: usize, size: usize) ![][]u8 {
if (count == 0 or size == 0) return error.InvalidArgs;
if (size > 4096) return error.SizeTooLarge;
const outer = try allocator.alloc([]u8, count);
errdefer allocator.free(outer);
var initialized: usize = 0;
errdefer for (outer[0..initialized]) |buf| allocator.free(buf);
for (0..count) |i| {
outer[i] = try allocator.alloc(u8, size);
@memset(outer[i], @intCast(i));
initialized += 1;
}
return outer;
}
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const buffers = try buildBuffers(allocator, 3, 4);
defer {
for (buffers) |buf| allocator.free(buf);
allocator.free(buffers);
}
for (buffers, 0..) |buf, i| {
std.debug.print("buf[{}]: {} {} {} {}\n", .{ i, buf[0], buf[1], buf[2], buf[3] });
}
// This call fails — errdefer inside buildBuffers cleans up
const bad = buildBuffers(allocator, 2, 8192);
if (bad) |_| unreachable else |err| {
std.debug.print("Expected failure: {s}\n", .{@errorName(err)});
}
}
The key insight is that errdefer tracks how many inner buffers were allocated so it can free exactly those — not more, not fewer. This pattern of initialized: usize as a progress counter is idiomatic Zig for partial-allocation rollback.
Composing Error Sets
When a function calls multiple other functions with different error sets, Zig automatically merges those sets into the inferred return type. You can also merge sets explicitly using the || operator, which produces a new set containing all values from both.
Explicit merging is useful when you want to name the combined set for documentation purposes, or when you need to write a function that accepts a value of the merged type.
const std = @import("std");
const MathError = error{ DivisionByZero, Overflow };
const ValidationError = error{ NegativeInput, InputTooLarge };
// Named merged set
const CalcError = MathError || ValidationError;
fn validate(n: i64) ValidationError!u32 {
if (n < 0) return ValidationError.NegativeInput;
if (n > 1_000_000) return ValidationError.InputTooLarge;
return @intCast(n);
}
fn safeDivide(a: u32, b: u32) MathError!u32 {
if (b == 0) return MathError.DivisionByZero;
return a / b;
}
fn compute(a: i64, b: i64) CalcError!u32 {
const va = try validate(a);
const vb = try validate(b);
return try safeDivide(va, vb);
}
pub fn main() void {
const cases = [_][2]i64{
.{ 100, 4 },
.{ 50, 0 },
.{ -10, 5 },
.{ 2_000_000, 1 },
};
for (cases) |pair| {
const a = pair[0];
const b = pair[1];
if (compute(a, b)) |result| {
std.debug.print("{} / {} = {}\n", .{ a, b, result });
} else |err| switch (err) {
error.DivisionByZero => std.debug.print("{} / {} = cannot divide by zero\n", .{ a, b }),
error.NegativeInput => std.debug.print("{} / {} = inputs must be positive\n", .{ a, b }),
error.InputTooLarge => std.debug.print("{} / {} = inputs must be <= 1,000,000\n", .{ a, b }),
error.Overflow => std.debug.print("{} / {} = arithmetic overflow\n", .{ a, b }),
}
}
}
The switch on err inside the else |err| switch (err) pattern is exhaustive — the compiler will reject it if you forget to handle any member of CalcError. This is one of the most powerful safety properties of named error sets.
Before moving on, assemble a merge yourself. The lines below are a function body that runs two stages, each with its own error set, and hands the caller a set covering both — shuffled, and one of the arrangements is not merely misordered but unbuildable.
Arrange the code
These lines form a function that runs a parse stage and a validate stage, each declaring its own error set, and reports a set that covers both. Reorder them into a body that builds and runs — then answer what the order is really testing: which lines are pinned by a genuine dependency, and which one is pinned by the fact that its value stops existing if it moves.
fn process(raw: []const u8) (ParseError || ValidateError)!u32 {const parsed = try parse(raw);return parsed;std.debug.print("accepted {}\n", .{parsed});try validate(parsed);}
Translating Errors Across Module Boundaries
Composing error sets works well inside a module, but at a public API boundary you often do not want to leak a dependency's error set to your callers. If your parser returns ParseError and your storage layer returns StorageError, a caller of your higher-level loadRecord function should not have to know — or handle — every failure mode of both layers. Instead you translate the low-level errors into a small, stable set that describes failures in your domain's terms.
The tool for this is catch |err| switch (err) that returns a different error. Each incoming error is mapped to one of your own, and the mapping is exhaustive — if a dependency adds a new error variant, the compiler forces you to decide how to translate it rather than silently passing it through.
const std = @import("std");
// Low-level error sets from two different "modules".
const ParseError = error{ InvalidCharacter, EmptyInput };
const StorageError = error{ NotFound, Corrupted };
// The stable, caller-facing error set for this API. Notice it says nothing
// about parsing or storage internals — it speaks the domain's language.
const LoadError = error{ BadRequest, Unavailable };
fn parseKey(input: []const u8) ParseError!u32 {
if (input.len == 0) return ParseError.EmptyInput;
var key: u32 = 0;
for (input) |c| {
if (c < '0' or c > '9') return ParseError.InvalidCharacter;
key = key * 10 + (c - '0');
}
return key;
}
fn fetch(key: u32) StorageError![]const u8 {
return switch (key) {
1 => "alice",
2 => "bob",
7 => error.Corrupted,
else => error.NotFound,
};
}
// The boundary: both dependency error sets are translated into LoadError.
// The switches are exhaustive, so adding a variant upstream won't compile
// until it's handled here.
fn loadRecord(raw_key: []const u8) LoadError![]const u8 {
const key = parseKey(raw_key) catch |err| switch (err) {
error.EmptyInput, error.InvalidCharacter => return error.BadRequest,
};
return fetch(key) catch |err| switch (err) {
error.NotFound, error.Corrupted => return error.Unavailable,
};
}
pub fn main() void {
const requests = [_][]const u8{ "1", "2", "abc", "99", "7" };
for (requests) |req| {
if (loadRecord(req)) |name| {
std.debug.print("'{s}' -> {s}\n", .{ req, name });
} else |err| switch (err) {
error.BadRequest => std.debug.print("'{s}' -> rejected (bad request)\n", .{req}),
error.Unavailable => std.debug.print("'{s}' -> record unavailable\n", .{req}),
}
}
}
A caller of loadRecord sees only LoadError — two clear, actionable outcomes — and is fully insulated from how many layers sit underneath. This is the error-handling equivalent of an abstraction boundary: the type signature is a contract, and translating errors at the edge is how you keep that contract stable while the internals evolve.
When you translate, resist the urge to collapse everything into a single generic error. Preserve distinctions the caller can act on (a malformed request is the caller's fault; an unavailable record is not) and collapse only the distinctions they cannot. The right size for a public error set is "every variant the caller might branch on, and no more."
That advice is easy to agree with and easy to violate, because collapsing errors makes a signature look tidier and the program still runs. The next one does exactly that. Commit a hypothesis before you change anything.
Debug
A client fetches a record and retries when the failure looks temporary. It compiles, runs, exits cleanly, and reports that it gave up on a key that was never coming back — after asking for it three times. Say what the retry loop is actually deciding on before you touch it, then fix the boundary so a missing record is refused immediately and reported as Permanent.
const std = @import("std");
const FetchError = error{ Timeout, NotFound, Malformed };
const ClientError = error{ Retryable, Permanent };
fn fetch(key: []const u8) FetchError!u32 {
if (std.mem.eql(u8, key, "slow")) return error.Timeout;
if (std.mem.eql(u8, key, "gone")) return error.NotFound;
if (std.mem.eql(u8, key, "junk")) return error.Malformed;
return 42;
}
fn load(key: []const u8) ClientError!u32 {
return fetch(key) catch error.Retryable;
}
fn loadWithRetry(key: []const u8) ClientError!u32 {
var attempts: u32 = 0;
while (attempts < 3) : (attempts += 1) {
const value = load(key) catch |err| switch (err) {
error.Retryable => continue,
error.Permanent => return err,
};
std.debug.print("{s}: got {} after {} retries\n", .{ key, value, attempts });
return value;
}
std.debug.print("{s}: gave up after 3 attempts\n", .{key});
return error.Retryable;
}
pub fn main() void {
for ([_][]const u8{ "ok", "gone" }) |key| {
_ = loadWithRetry(key) catch |err| {
std.debug.print("{s}: giving up, {s}\n", .{ key, @errorName(err) });
};
}
}Expected output:
ok: got 42 after 0 retries gone: giving up, Permanent
errdefer with a Captured Error
errdefer can capture the error that triggered it, which is useful when cleanup depends on why the function failed — for logging, metrics, or conditional rollback. The captured value has the function's error-set type and is only bound on the error path.
const std = @import("std");
const OpenError = error{ PermissionDenied, TooManyHandles };
fn openHandle(open_handles: *u32, user_id: u32) OpenError!u32 {
if (user_id == 0) return error.PermissionDenied;
if (open_handles.* >= 3) return error.TooManyHandles;
open_handles.* += 1;
// The handle is "acquired". If a later step fails, errdefer releases it
// and can inspect which error caused the failure.
errdefer |err| {
open_handles.* -= 1;
std.debug.print(" rollback: released handle after {s}\n", .{@errorName(err)});
}
// A second validation step that can still fail after acquisition.
if (user_id > 100) return error.PermissionDenied;
return open_handles.*;
}
pub fn main() void {
var open_handles: u32 = 0;
const users = [_]u32{ 5, 200, 0 };
for (users) |uid| {
if (openHandle(&open_handles, uid)) |handle| {
std.debug.print("user {}: opened handle #{}\n", .{ uid, handle });
} else |err| {
std.debug.print("user {}: failed with {s}\n", .{ uid, @errorName(err) });
}
}
std.debug.print("open handles still held: {}\n", .{open_handles});
}
The errdefer |err| capture only runs for user_id == 200, where the handle was acquired and then a later check failed — exactly the case where rollback matters. For user_id == 0 the function returns before acquiring anything, so the errdefer never registered and nothing is released. This precision — cleaning up only what was actually acquired, and only when it was — is why errdefer is the backbone of leak-free Zig code.
There is one more thing to pin down about that captured error, and it only shows up when translation and rollback appear in the same function — which, in real code, is most of the time. Predict what the rollback logs.
Predict
This function acquires a resource, registers an errdefer that logs the error it captured, and then translates two low-level errors into one of its own. Which error name does the rollback line print for key 1 and key 2?
const std = @import("std");
const Low = error{ Missing, Corrupt };
const High = error{Unavailable};
var acquired: u32 = 0;
fn low(key: u32) Low!u32 {
if (key == 1) return error.Missing;
if (key == 2) return error.Corrupt;
return key;
}
fn high(key: u32) High!u32 {
acquired += 1;
errdefer |err| {
acquired -= 1;
std.debug.print(" rollback after {s}\n", .{@errorName(err)});
}
return low(key) catch |err| switch (err) {
error.Missing, error.Corrupt => return error.Unavailable,
};
}
pub fn main() void {
for ([_]u32{ 5, 1, 2 }) |k| {
if (high(k)) |v| {
std.debug.print("{d} -> {d}\n", .{ k, v });
} else |err| {
std.debug.print("{d} -> {s}\n", .{ k, @errorName(err) });
}
}
std.debug.print("still acquired: {d}\n", .{acquired});
}Both print rollback after Unavailable. The captured value has the enclosing function's error-set type — here High — so it can only hold what high itself returns. The translation happens inside the catch, before the function unwinds, so by the time the errdefer fires the original Missing or Corrupt is gone. The rollback itself is still correct: both failing keys release what they acquired, leaving the one still-held resource from the successful key. What you lose is the upstream cause. If a rollback needs to know why the dependency failed, log it inside the catch where the original error is still in hand, not in the errdefer.
Try It Yourself
Reading these patterns is not the same as reaching for them under pressure. This is a build
task: a small program that reports its own pass/fail. Three functions are stubbed out — one
parses a score and rejects bad input, one translates those parse errors into the two the caller
is allowed to see, and one runs a batch, skipping failures while keeping the two kinds countable.
Run it as-is and it fails immediately, naming the first check that did not pass. Implement each
one until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper, which prints the
failure and returns an error. Because an error returned from main ends the program with a
non-zero exit status, the first failing check stops everything. Two more helpers, isValue and
isError, exist because an error union cannot be compared with == directly — each one opens
the union and reports whether it held the value or the error the check expected.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one behaves correctly. Run it as-is to see which check fails first, then work down: parse, translate, tally. The checks run in TODO order, so the first failure you see is TODO 1. Keep going until it prints 'All checks passed.'
const std = @import("std");
const ScoreError = error{ InvalidFormat, OutOfRange };
const LoadError = error{ BadInput, Unusable };
// TODO 1: parse a run of digits into a u8 score.
// Reject anything that is not a digit with error.InvalidFormat, reject an
// empty input the same way, and reject a value above 100 with
// error.OutOfRange.
// "87" -> 87 "abc" -> error.InvalidFormat "102" -> error.OutOfRange
fn parseScore(input: []const u8) ScoreError!u8 {
_ = input;
return 0;
}
// TODO 2: translate the two parse errors into the two the caller is allowed to
// see, keeping the distinction the caller can act on: a malformed entry is
// the caller's fault (BadInput), an out-of-range one is data we cannot use
// (Unusable).
// "abc" -> error.BadInput "102" -> error.Unusable "73" -> 73
fn loadScore(input: []const u8) LoadError!u8 {
_ = input;
return error.BadInput;
}
// TODO 3: sum the entries that load, count the ones that do not, and leave the
// two failure kinds distinguishable. Skip a failing entry, do not abandon
// the batch.
// { "10", "abc", "20" } -> total 30, bad 1, unusable 0
fn tally(entries: []const []const u8, bad: *u32, unusable: *u32) u32 {
_ = entries;
_ = bad;
_ = unusable;
return 0;
}
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// Two more helpers the checks use to compare against an error union.
fn isValue(actual: anytype, want: u8) bool {
const got = actual catch return false;
return got == want;
}
fn isError(actual: anytype, want: anyerror) bool {
if (actual) |_| return false else |err| return err == want;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try check(isValue(parseScore("87"), 87), "parseScore(\"87\") should be 87");
try check(isValue(parseScore("0"), 0), "parseScore(\"0\") should be 0, not an error");
try check(isValue(parseScore("100"), 100), "parseScore(\"100\") should be 100: the range is inclusive");
try check(isError(parseScore("101"), error.OutOfRange), "parseScore(\"101\") should be error.OutOfRange, not 101");
try check(isError(parseScore("abc"), error.InvalidFormat), "parseScore(\"abc\") should be error.InvalidFormat");
try check(isError(parseScore(""), error.InvalidFormat), "parseScore(\"\") should be error.InvalidFormat");
try check(isValue(loadScore("73"), 73), "loadScore(\"73\") should pass the value straight through");
try check(isError(loadScore("abc"), error.BadInput), "loadScore should translate InvalidFormat to BadInput");
try check(isError(loadScore("102"), error.Unusable), "loadScore should translate OutOfRange to Unusable, not BadInput");
var bad: u32 = 0;
var unusable: u32 = 0;
const entries = [_][]const u8{ "87", "102", "73", "abc", "95", "0", "100" };
const total = tally(&entries, &bad, &unusable);
try check(total == 355, "tally should add only the entries that loaded: 87+73+95+0+100 is 355");
try check(bad == 1, "tally should count exactly one malformed entry (\"abc\")");
try check(unusable == 1, "tally should count exactly one out-of-range entry (\"102\")");
var none_bad: u32 = 0;
var none_unusable: u32 = 0;
const clean = [_][]const u8{ "10", "20" };
try check(tally(&clean, &none_bad, &none_unusable) == 30, "tally over clean input should be 30");
try check(none_bad == 0 and none_unusable == 0, "tally over clean input should count no failures");
std.debug.print("All checks passed.\n", .{});
std.debug.print("total={} bad={} unusable={}\n", .{ total, bad, unusable });
}Expected output:
All checks passed. total=355 bad=1 unusable=1
Once it passes, try two variations and predict each before running:
- Collapse the translation. In
loadScore, replace the switch with a barecatch error.BadInput, so both upstream errors become the same thing. Predict which check fails before running. It compiles and runs — nothing in the types objects to it — but the checkloadScore should translate OutOfRange to Unusable, not BadInputfails, and so does theunusablecount intally. This is the same defect as the DebugBlock above, caught here by an assertion instead of by a retry loop wasting three attempts. - Make the range exclusive. In
parseScore, changeif (n > 100)toif (n >= 100). Predict whether this fails at build time or at run time, and which check goes first. It builds fine and fails at run time onparseScore("100") should be 100: the range is inclusive— the boundary check exists precisely to pin>against>=, because both readings of "up to 100" are plausible until something asserts one.
Everything in this lesson is composition on top of one primitive from much earlier in the track. Check that the foundation is still solid.
Recall
Without scrolling up: this lesson's patterns — errdefer rollback, || merging, catch-and-switch translation — all rest on what an error union actually is. Error Handling introduced E!T, and Optionals and Unions introduced ?T. What is the relationship, and why can the compiler check an error switch exhaustively?
So the whole lesson rests on one property: E!T is a union of a success value and one member of a closed error set known at compile time. That is what lets the compiler reject an incomplete switch, what makes || produce another checkable set, and what makes adding a variant upstream a build failure rather than a runtime surprise. ?T from Optionals and Unions is the same shape with a single, information-free failure case — which is why orelse is enough for it and errors need a switch.
Composing error sets is where Zig's approach and Rust's diverge most visibly, even though both are solving the same problem. It is worth being exact about which half carries over.
Transfer
A Zig function that calls two fallible stages declares (ParseError || ValidateError)!u32 and the compiler checks every switch against that merged set. A Rust function calling two fallible stages typically declares Result<u32, MyError> and converts each upstream error into MyError with the From trait, invoked automatically by ?. Which statement names the problem both designs are solving, and the mechanism they genuinely share?
Key Takeaways
- Error sets are closed types. Defining your own error sets makes failure modes explicit in the API contract and enables exhaustive switch handling.
trypropagates,catchrecovers. Usetryby default; reach forcatchonly when you have a genuine recovery strategy at that call site.errdeferis for partial-allocation rollback. It runs only on error exit, making it the precise tool for cleaning up resources acquired mid-function without leaking memory on success.- Error sets compose with
||. You can merge sets explicitly to name combined failure domains, or let Zig infer the union automatically when youtrymultiple functions. - Translate errors at module boundaries. Use
catch |err| switch (err)to map a dependency's error set onto your own stable, caller-facing set — preserve distinctions the caller can act on, collapse the rest, and let the exhaustive switch force you to handle new upstream variants. errdefer |err|can capture the failing error. Bind the error on the cleanup path when rollback or logging needs to know why the function failed; it runs only if the resource was actually acquired before the failure.@errorName(err)returns the error as a string. This is useful for logging and debugging without hard-coding string literals.- Exhaustive switches catch missing cases at compile time. Switching on a named error set is safer than switching on
anyerrorbecause the compiler enforces completeness.
Pro Tip: Avoid using
anyerrorin public API return types. It erases the error set, preventing callers from writing exhaustive switches and making your API harder to reason about. Prefer inferred or explicit named sets — the compiler will tell you exactly what can go wrong, and so will your documentation.
Everything in this track is pinned to Zig 0.16.0. When 0.17 lands, the error-handling core you
learned here — error sets, try, catch, errdefer, and || merging — is among the most stable
parts of the language and should hold unchanged; it is the build.zig and I/O surfaces that move.
Next Steps
You've mastered error handling for individual functions and modules. The next extension takes on a different kind of abstraction: how Zig achieves polymorphism through tagged unions and vtables, with no interface keyword anywhere in the language.