Error Handling in Zig
20 August, 2025
I find Zig’s error handling model remarkably elegant. It takes a different path from traditional exception systems by combining compile-time guarantees with runtime clarity. The result is a model that preserves performance while keeping code readable and maintainable.
This article gives a practical overview of how Zig handles errors and why its design is so interesting.
1. Error Sets
In Zig, errors are treated as values, not exceptions. You can define an error type with an error set, which contains a fixed set of possible error names.
const FileError = error{
NotFound,
PermissionDenied,
InvalidPath,
};
This defines an error set named FileError. A value of type FileError can only be one of NotFound, PermissionDenied, or InvalidPath. This is similar to an enum, but it is designed specifically for representing error types.
2. Error Unions
Zig uses error unions to combine errors with regular return values. The syntax is !T or error{...}!T.
!T: the function returns either a value of typeTor an error.error{...}!T: a more explicit form whereerror{...}declares the possible error set.
fn readNumber(str: []const u8) !u32 {
if (str.len == 0) return error.EmptyString;
return std.fmt.parseInt(u32, str, 10);
}
pub fn main() !void {
const num = try readNumber("123");
std.debug.print("The number is: {}\n", .{num});
// This will return an error
const bad_num = readNumber("abc") catch |err| {
std.debug.print("Error: {}\n", .{err});
return err;
};
}
In this example, readNumber returns !u32, which means it either returns a u32 value or an error. The catch keyword catches the error and lets you define custom error handling logic.
Note that ! here is not boolean negation. It is a type constructor that combines an error set with a payload type into an error union type.
3. Error Propagation and Catching
Zig provides a concise and expressive way to handle errors: try and catch.
try: shorthand forcatch |err| return err. If the expression aftertryreturns an error, the error is immediately propagated to the caller.catch: catches an error and provides custom handling logic.
fn processFile() !void {
// 'try' desugars to 'catch |err| return err'
const file = try std.fs.cwd().openFile("data.txt", .{});
defer file.close();
// Different ways to handle errors
const data = readData() catch |err| switch(err) {
error.OutOfMemory => return error.CannotProcess,
error.InvalidData => return error.BadFormat,
else => return err,
};
}
This snippet shows the basic usage of try and catch, as well as how switch can be used in error handling.
4. Error Return Traces
Zig supports error return traces, which can print the full propagation path of an error when needed. The key detail is that when tracing is not enabled or not used, it does not add runtime overhead.
fn deepFunction() !void {
return error.SomethingWentWrong;
}
fn middleFunction() !void {
try deepFunction();
}
pub fn main() void {
middleFunction() catch |err| {
// With error return tracing enabled, you can print the full error propagation path
std.debug.print("Error: {}\n", .{err});
return;
};
}
To enable error tracing, you need to add the relevant compiler option. Once enabled, you can see the complete error call chain when an error occurs, which makes debugging much easier.
5. Main Advantages
Compared with traditional exception handling, Zig’s error handling model has several important advantages:
- No hidden control flow: error propagation is explicit in both types and syntax, making execution flow clearer and easier to reason about.
- Compile-time checks: function signatures clearly declare possible error sets. Missing handling can be caught during compilation instead of becoming a runtime surprise.
- Low runtime overhead: unused error tracing does not introduce extra performance cost.
- Clear propagation boundaries:
tryandcatchare concise and direct, so you do not need to fill every corner of the codebase with verbosetry/catchblocks.
6. Best Practices
- Use specific error sets: define module-specific or function-specific error sets so the possible failures are precise.
- Choose readable names: use clear and actionable error names such as
InvalidPathorAccessDenied. - Cover all cases: rely on compile-time checks to ensure every possible error is either handled or correctly propagated.
const DatabaseError = error{
ConnectionFailed,
QueryFailed,
InvalidData,
};
fn queryDatabase() DatabaseError!Data {
// implementation omitted
}
7. Practical Example: File Processing Flow
The example below shows how to define a more complete error set for a file processing workflow and how to handle different error types inside a loop:
const std = @import("std");
// Extend the error set to cover common file-related errors
// and union it with the standard library's OpenError / ReadError.
const ProcessError = error{
FileNotFound,
InvalidFormat,
ProcessingFailed,
AccessDenied,
SystemResources,
IsDir,
NoSpaceLeft,
InputOutput,
OperationAborted,
BrokenPipe,
ConnectionResetByPeer,
ConnectionTimedOut,
NotOpenForReading,
} || std.fs.File.OpenError || std.fs.File.ReadError;
fn processDocument(path: []const u8) ProcessError!void {
// Open the file
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
// Read content
var buffer: [1024]u8 = undefined;
const size = try file.readAll(&buffer);
// Business validation
if (size < 10) return ProcessError.InvalidFormat;
// Business processing logic
std.debug.print("Successfully processed {} bytes\n", .{size});
}
pub fn main() !void {
// Allocator required by the example
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Files to process
const files = [_][]const u8{
"document1.txt",
"missing.txt",
"small.txt",
"document2.txt",
};
// Process files one by one and classify errors
for (files) |file_path| {
processDocument(file_path) catch |err| switch (err) {
error.FileNotFound => {
std.debug.print("Warning: File not found: {s}\n", .{file_path});
continue;
},
error.InvalidFormat => {
std.debug.print("Error: Invalid format in file: {s}\n", .{file_path});
continue;
},
error.AccessDenied => {
std.debug.print("Error: Access denied for file: {s}\n", .{file_path});
continue;
},
error.IsDir => {
std.debug.print("Error: Path is a directory: {s}\n", .{file_path});
continue;
},
// Group all other errors
else => {
std.debug.print("Unexpected error while processing {s}: {}\n", .{ file_path, err });
continue;
},
};
std.debug.print("Successfully processed file: {s}\n", .{file_path});
}
// Write a processing log
const log_file = try std.fs.cwd().createFile(
"processing_log.txt",
.{ .read = true },
);
defer log_file.close();
// Write summary information
const timestamp = std.time.timestamp();
const log_message = try std.fmt.allocPrint(
allocator,
"Processing completed at: {d}\n",
.{timestamp},
);
defer allocator.free(log_message);
_ = try log_file.writeAll(log_message);
}
This example demonstrates how to define and use error sets in a realistic file processing scenario, and how to choose different handling strategies based on specific error types.
Conclusion
Among systems programming languages, Zig’s error handling model stands out. It provides the type safety you might expect from Rust, keeps the directness and lightness often associated with Go, and adds its own distinct features, such as compile-time error sets and low-overhead error return traces.
These design choices provide:
- No hidden control flow
- Stronger compile-time guarantees
- Clearer error propagation boundaries
Those properties match the control and transparency that systems programming often requires. I hope this article helps you better understand and apply Zig’s error handling model so you can write more robust and reliable Zig programs.