TL;DR
Master Zig error handling — use error unions, error sets, try/catch, and errdefer to write robust code that fails gracefully without exceptions.
Key concepts
- zig error handling
- zig error unions
- zig try catch
- zig errdefer
Error Handling
Zig's error handling is one of its most distinctive features. Instead of exceptions, Zig uses error unions and error sets that integrate directly into the type system, giving you explicit, composable, and zero-cost error handling.
What You'll Learn
By the end of this lesson you will write a function that can fail, say so in its return type, and clean up after itself when it does — and you will be able to call such a function three different ways depending on who should be responsible for the failure. The build task asks you to parse values that may be malformed and report which input was bad rather than just that something was.
This is the error-pipeline milestone the record analyzer is built around. A real data file has rows that do not parse, and the analyzer's job is to keep going and tell you exactly which rows it rejected and why — none of which is possible until failure is a value you can return, inspect, and pass along. You arrive here able to write a function with parameters and a return type (Functions); what is new is that the return type can now carry a failure alongside the result.
Error Unions
An error union combines a normal return type with a possible error. The syntax is ErrorSet!ReturnType.
const std = @import("std");
fn divide(numerator: f64, denominator: f64) !f64 {
if (denominator == 0.0) {
return error.DivisionByZero;
}
return numerator / denominator;
}
pub fn main() !void {
const result1 = divide(10.0, 3.0) catch |err| {
std.debug.print("Error: {}\n", .{err});
return;
};
std.debug.print("10 / 3 = {d:.4}\n", .{result1});
_ = divide(5.0, 0.0) catch |err| {
std.debug.print("Caught error: {}\n", .{err});
return;
};
}
Custom Error Sets
Define your own error sets to describe exactly what can go wrong.
const std = @import("std");
const ParseError = error{
InvalidCharacter,
Overflow,
EmptyInput,
};
fn parseAge(input: []const u8) ParseError!u8 {
if (input.len == 0) return ParseError.EmptyInput;
var result: u16 = 0;
for (input) |c| {
if (c < '0' or c > '9') return ParseError.InvalidCharacter;
result = result * 10 + (c - '0');
if (result > 255) return ParseError.Overflow;
}
return @intCast(result);
}
pub fn main() !void {
const age = parseAge("25") catch |err| {
std.debug.print("Parse failed: {}\n", .{err});
return;
};
std.debug.print("Parsed age: {}\n", .{age});
_ = parseAge("abc") catch |err| {
std.debug.print("Expected error for 'abc': {}\n", .{err});
return;
};
}
The try Keyword
try is syntactic sugar for propagating errors upward automatically.
const std = @import("std");
const MathError = error{
DivisionByZero,
NegativeSquareRoot,
};
fn safeDivide(a: f64, b: f64) MathError!f64 {
if (b == 0.0) return MathError.DivisionByZero;
return a / b;
}
fn safeSqrt(x: f64) MathError!f64 {
if (x < 0.0) return MathError.NegativeSquareRoot;
return @sqrt(x);
}
fn calculateFormula(a: f64, b: f64, c: f64) MathError!f64 {
const ratio = try safeDivide(a, b);
const root = try safeSqrt(ratio);
return root + c;
}
pub fn main() !void {
const result = calculateFormula(16.0, 4.0, 1.0) catch |err| {
std.debug.print("Calculation failed: {}\n", .{err});
return;
};
std.debug.print("Result: {d:.2}\n", .{result});
_ = calculateFormula(10.0, 0.0, 1.0) catch |err| {
std.debug.print("Expected error: {}\n", .{err});
return;
};
}
try is short, but it hides a control-flow decision worth spelling out. Trace exactly which lines run below before you read on.
Predict
runAll calls three steps with try, and the second one fails. Which lines get printed, in order?
const std = @import("std");
const StepError = error{Failed};
fn step(n: u8) StepError!u8 {
std.debug.print("running step {}\n", .{n});
if (n == 2) return StepError.Failed;
return n;
}
fn runAll() StepError!u8 {
const a = try step(1);
const b = try step(2);
const c = try step(3);
return a + b + c;
}
pub fn main() !void {
const total = runAll() catch |err| {
std.debug.print("runAll failed: {s}\n", .{@errorName(err)});
return;
};
std.debug.print("total: {}\n", .{total});
}Only the first two steps run: running step 1, running step 2, then runAll failed: Failed. try is an early return in disguise — when step(2) hands back an error it returns that error out of runAll immediately, abandoning step(3) and the sum, so main's catch handles it and total: never prints.
The errdefer Keyword
errdefer runs cleanup only when the function returns an error.
const std = @import("std");
const Config = struct {
name: []const u8,
value: i32,
};
fn processConfig(valid: bool) !Config {
std.debug.print("Step 1: Allocating resources...\n", .{});
errdefer {
std.debug.print("Cleanup: Rolling back due to error!\n", .{});
}
std.debug.print("Step 2: Validating...\n", .{});
if (!valid) {
return error.InvalidConfig;
}
std.debug.print("Step 3: Success!\n", .{});
return Config{ .name = "production", .value = 42 };
}
pub fn main() !void {
std.debug.print("--- Success case ---\n", .{});
const config = processConfig(true) catch |err| {
std.debug.print("Failed: {}\n", .{err});
return;
};
std.debug.print("Config: {s} = {}\n\n", .{ config.name, config.value });
std.debug.print("--- Error case ---\n", .{});
_ = processConfig(false) catch |err| {
std.debug.print("Failed: {}\n", .{err});
return;
};
}
Now build one yourself. The lines below are the body of a function that claims a numbered slot, may fail partway through, and must give the slot back when it does — shuffled, and one of the arrangements looks fine and quietly leaks.
Arrange the code
These lines form a function that claims a slot from a pool, may fail its handshake, and must release the slot when it does. claimSlot and releaseSlot are already written. Reorder the body so the failing call releases what it claimed — then answer what the order is really testing: at what moment does an errdefer begin to apply, and what goes wrong at each of the two places it must not sit?
std.debug.print("session ready in slot {}\n", .{slot});fn openSession(ok: bool) !u32 {return slot;const slot = claimSlot();if (!ok) return error.HandshakeFailed;errdefer releaseSlot(slot);
You have now seen error unions written three ways across two lessons. Close the code and check that the vocabulary lines up.
Recall
Without scrolling up: back in Functions you called an error-returning function three different ways — with try, with catch, and with an if/else |err| pair, which Control Flow then showed capturing the error on the else side. Given a function fn load() MyError!u32, which row matches each form to what it produces?
All three unwrap the same error union and differ only in what the error case does to control flow: try returns the error out of the enclosing function, catch supplies a value or runs a block and carries on locally, and if (load()) |v| ... else |err| ... handles both outcomes inline without leaving. Picking between them is really picking who owns the failure. errdefer is the fourth member of the family — it unwraps nothing and just registers cleanup for the paths where an error is what leaves.
Error Merging and Switching
Merge error sets with || and switch on specific errors.
const std = @import("std");
const ReadError = error{ EndOfStream, Timeout };
const ParseError = error{ InvalidFormat, Overflow };
const ProcessError = ReadError || ParseError;
fn processData(stage: u8) ProcessError!i32 {
return switch (stage) {
0 => error.EndOfStream,
1 => error.Timeout,
2 => error.InvalidFormat,
3 => error.Overflow,
else => @as(i32, 42),
};
}
pub fn main() !void {
for (0..6) |i| {
const stage: u8 = @intCast(i);
const result = processData(stage) catch |err| {
switch (err) {
error.EndOfStream => std.debug.print("Stage {}: End of data\n", .{stage}),
error.Timeout => std.debug.print("Stage {}: Timed out\n", .{stage}),
error.InvalidFormat => std.debug.print("Stage {}: Bad format\n", .{stage}),
error.Overflow => std.debug.print("Stage {}: Value too large\n", .{stage}),
}
continue;
};
std.debug.print("Stage {}: Got value {}\n", .{ stage, result });
}
}
catch is the tool that stops an error propagating — which also makes it the tool that can make an error disappear without anyone noticing. The next program compiles, runs, exits cleanly, and reports a number that is wrong.
Debug
Three inputs go in — '12', 'oops' and '30' — and one of them is not a number. The program should refuse the batch and print 'Rejected input: InvalidCharacter'. Instead it exits successfully with a total. Commit a hypothesis about where the error went, then fix it so the bad input is reported.
const std = @import("std");
const ParseError = error{ EmptyInput, InvalidCharacter };
fn parseCount(input: []const u8) ParseError!u32 {
if (input.len == 0) return ParseError.EmptyInput;
var total: u32 = 0;
for (input) |c| {
if (c < '0' or c > '9') return ParseError.InvalidCharacter;
total = total * 10 + (c - '0');
}
return total;
}
fn sumAll(inputs: []const []const u8) u32 {
var sum: u32 = 0;
for (inputs) |input| {
sum += parseCount(input) catch 0;
}
return sum;
}
pub fn main() !void {
const inputs = [_][]const u8{ "12", "oops", "30" };
std.debug.print("Total: {}\n", .{sumAll(&inputs)});
}Expected output:
Rejected input: InvalidCharacter
The error is raised correctly and then discarded: catch 0 substitutes zero for "oops", so the total is 42 (12 + 0 + 30) with a clean exit status and no hint that a third of the input was garbage. The root cause is in the signature — sumAll returns a plain u32, so it has no way to report failure and the catch is forced. Widening the return type to ParseError!u32 and using try parseCount(input) lets the error travel to main, which can then name it. A catch with a default is right when a missing value has a sensible substitute, and wrong when the caller needs to know something failed.
Zig is not the only language that makes failure a value in the return type. Before moving on, it is worth separating the part of that idea that transfers from the part that does not.
Transfer
Zig writes a fallible function as fn load() MyError!u32 and unwraps it at the call site with try. Rust writes the same function as fn load() -> Result<u32, MyError> and unwraps it with the ? operator. Which statement names what the two genuinely share, rather than a resemblance that breaks down?
Try It Yourself
Reading about error unions is not the same as deciding where a failure belongs. This is a build task: a small program that reports its own pass/fail. Three functions are stubbed out — one raises named errors, one substitutes a default, one propagates. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each function until every check passes and it prints All checks passed.
There is no assert to import: the starter defines its own check helper that 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 — no hidden control flow, exactly as Zig prefers it.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each handles failure the way its signature promises. Run it as-is to see which check fails first, decide whether that function should raise, substitute, or propagate, then implement all three until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const std = @import("std");
const RecordError = error{ EmptyField, NotANumber, OutOfRange };
// 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;
}
}
// TODO 1: parse a score field into a u32, RAISING a named error for each way
// the field can be bad. Build the number digit by digit.
// "" -> RecordError.EmptyField
// "8a" -> RecordError.NotANumber
// "900" -> RecordError.OutOfRange (anything above 100)
// "87" -> 87
fn parseScore(field: []const u8) RecordError!u32 {
_ = field;
return 0;
}
// TODO 2: return the parsed score, or SUBSTITUTE fallback when the field is
// bad. Note the return type: plain u32, so no error can escape here.
// scoreOrDefault("42", 0) -> 42, scoreOrDefault("nope", 7) -> 7
fn scoreOrDefault(field: []const u8, fallback: u32) u32 {
_ = field;
_ = fallback;
return 0;
}
// TODO 3: total every field, PROPAGATING the first error instead of skipping
// the bad field. Note the return type: RecordError!u32.
// totalValid(&.{ "10", "20", "30" }) -> 60
// totalValid(&.{ "10", "x", "30" }) -> RecordError.NotANumber
fn totalValid(fields: []const []const u8) RecordError!u32 {
_ = fields;
return 0;
}
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
try check(try parseScore("87") == 87, "parseScore(\"87\") should be 87");
try check(try parseScore("0") == 0, "parseScore(\"0\") should be 0");
try check(parseScore("") == RecordError.EmptyField, "an empty field should be error.EmptyField");
try check(parseScore("8a") == RecordError.NotANumber, "a non-digit should be error.NotANumber");
try check(parseScore("900") == RecordError.OutOfRange, "anything above 100 should be error.OutOfRange");
try check(scoreOrDefault("42", 0) == 42, "scoreOrDefault should return the parsed value when the field is good");
try check(scoreOrDefault("nope", 7) == 7, "scoreOrDefault should return the fallback 7 when the field is bad");
const good = [_][]const u8{ "10", "20", "30" };
const bad = [_][]const u8{ "10", "x", "30" };
try check(try totalValid(&good) == 60, "totalValid should add three good fields to 60");
try check(totalValid(&bad) == RecordError.NotANumber, "totalValid should PROPAGATE the bad field, not skip it");
std.debug.print("All checks passed.\n", .{});
std.debug.print("Parsed: {}\n", .{try parseScore("87")});
std.debug.print("Fallback: {}\n", .{scoreOrDefault("nope", 7)});
std.debug.print("Total of good fields: {}\n", .{try totalValid(&good)});
}Expected output:
All checks passed. Parsed: 87 Fallback: 7 Total of good fields: 60
Once it passes, try two variations and predict each before running:
- Swallow instead of propagate. In
totalValid, changesum += try parseScore(field);tosum += parseScore(field) catch 0;. Predict which check fails before running. ThetotalValid(&good)check still passes, but the bad batch now returns40instead of an error, so the check demanding propagation fails. Same loop, same parser — only the error policy changed, and the function went from refusing bad data to silently under-reporting. - Move the range check outside the loop. In
parseScore, take theif (total > 100) return RecordError.OutOfRange;line out of the loop and put it just before the finalreturn total;. Predict which check fails before running. All fiveparseScorechecks still pass —"900"reaches 900 and is caught on the way out. The behaviour only diverges for input long enough to overflowu32before the check runs, which is the real reason to test the bound as the value grows rather than after.
Capstone milestone
Milestone — the error pipeline. The capstone's Record Analyzer reads rows that may be malformed, and every row has to end up in exactly one of three places: parsed, defaulted, or refused. The validator you just built is that pipeline in miniature. Confirm you can decide where a failure belongs and encode that decision in a signature.
Hint: You don't need the full analyzer yet — this confirms the failure-routing fluency the pipeline is built on. In the capstone, the row parser raises, the per-field reader may default, and the batch driver propagates so a malformed file is reported rather than quietly half-processed.
- Defined a named error set with one variant per distinct way the input can be bad
- Wrote a function that raises those errors rather than returning a sentinel value like -1
- Wrote a function whose plain (non-error) return type forces it to substitute a default with catch
- Wrote a function that propagates the first failure with try, and confirmed the later items are abandoned
Key Takeaways
- Error unions (
!T) combine error information with return values in the type system - Custom error sets document exactly what can go wrong in a function
trypropagates errors upward automatically, keeping code cleancatchlets you handle errors at the call site with full controlerrdeferensures cleanup runs only on error paths- Error sets can be merged with
||and switched on for granular handling - Zig's error handling has zero runtime overhead compared to exceptions
Pro Tip: Prefer specific error sets over the inferred
!when writing library code. Explicit error sets likeParseError!u8serve as documentation and help callers know exactly which errors to handle. Use the inferred!for application code where convenience matters more.
Next Steps
Now that you can handle errors gracefully, it's time to look at the other way a value can be absent: optionals. Next, you'll learn how ?T models "a value or nothing" without null pointers, and how unions let one value take several shapes.
Ready to try it? Continue in your learning studio to practice these ideas, save your progress, and experiment with code.