Minimal Viable Zig Error Contexts

matklad Tools

Summary

A blog post detailing a minimal pattern for adding error context in Zig using errdefer logging, comparing it to full diagnostics sinks and catch blocks, and discussing tradeoffs.

<header> <h1>Minimal Viable Zig Error Contexts</h1> <time class="meta" datetime="2026-05-03">May 3, 2026</time> </header> <figure class="code-block"> <pre><code><span class="line"><span class="hl-keyword">fn</span><span class="hl-function"> process_file</span>(io: Io, path: []<span class="hl-keyword">const</span> <span class="hl-type">u8</span>) <span class="hl-operator">!</span><span class="hl-type">void</span> {</span> <span class="line"> <span class="hl-keyword">errdefer</span> log.err(<span class="hl-string">&quot;path={s}&quot;</span>, .{path});</span> <span class="line"></span> <span class="line"> <span class="hl-keyword">const</span> fd = <span class="hl-keyword">try</span> Io.Dir.cwd().openFile(io, path, .{});</span> <span class="line"> <span class="hl-keyword">defer</span> fd.close(io);</span> <span class="line"></span> <span class="line"> <span class="hl-comment">// ...</span></span> <span class="line">}</span></code></pre> </figure> <p>Out of the box, Zig provides minimal and sufficient facilities for error <em>handling</em> — <a href="https://matklad.github.io/2025/11/06/error-codes-for-control-flow.html">strongly-typed error codes</a>. Error <em>reporting</em> is left to the user. Idiomatic solution is to pass a <code>Diagnostics</code> out parameter (“sink”) to materialize human-readable strings as needed.</p> <p>Diagnostics pattern works well for “production” code, but for more script-y code it adds too much friction relative to the default option of a plain <span class="display"><code>try fallible()</code>,</span> which of course gives a less than ideal message on failure:</p> <figure class="code-block"> <pre><code><span class="line">λ zig build</span> <span class="line">error: FileNotFound</span> <span class="line">~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail)</span> <span class="line"> .NOENT =&gt; return error.FileNotFound,</span> <span class="line"> ^</span> <span class="line">~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail)</span> <span class="line"> return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options);</span> <span class="line"> ^</span> <span class="line">~/fail/main.zig:10:16: 0x10443da5f in f (fail)</span> <span class="line"> const fd = try Io.Dir.cwd().openFile(io, path, .{});</span> <span class="line"> ^</span> <span class="line">~/fail/main.zig:6:5: 0x10443db47 in main (fail)</span> <span class="line"> try process_file(io, &quot;data.txt&quot;);</span> <span class="line"> ^</span></code></pre> </figure> <p>Error trace is helpful, but knowing <em>which</em> file is the problem is even more so.</p> <p>The first attempt at finding a middle ground between fully-fledged diagnostics sink pattern and a plain try is something like this:</p> <figure class="code-block"> <pre><code><span class="line"><span class="hl-keyword">const</span> fd = dir.openFile(io, path, .{}) <span class="hl-keyword">catch</span> <span class="hl-operator">|</span>err<span class="hl-operator">|</span> {</span> <span class="line"> log.err(<span class="hl-string">&quot;failed to open file &#x27;{s}&#x27;: {t}&quot;</span>, .{path, err});</span> <span class="line"> <span class="hl-keyword">return</span> err;</span> <span class="line">}</span></code></pre> </figure> <p>Unsatisfactory. The friction is high, you need to come up with a reasonably-sounding error message, the “happy path” of the code is obscured, and you need to repeat this for every fallible operation.</p> <p>A worse-is-better version of the above code is</p> <figure class="code-block"> <pre><code><span class="line"><span class="hl-keyword">errdefer</span> log.err(<span class="hl-string">&quot;path={s}&quot;</span>, .{path});</span> <span class="line"><span class="hl-keyword">const</span> fd = <span class="hl-keyword">try</span> dir.openFile(io, path, .{});</span></code></pre> </figure> <p>That is, just log error context as <code>key=value</code> pairs, guarded by <code>errdefer</code>. The result is not pretty, but passable:</p> <figure class="code-block"> <pre><code><span class="line">λ zig build</span> <span class="line">error: path=./data.txt</span> <span class="line">error: FileNotFound</span> <span class="line">~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail)</span> <span class="line"> .NOENT =&gt; return error.FileNotFound,</span> <span class="line"> ^</span> <span class="line">~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail)</span> <span class="line"> return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options);</span> <span class="line"> ^</span> <span class="line">~/fail/main.zig:10:16: 0x10443da5f in f (fail)</span> <span class="line"> const fd = try Io.Dir.cwd().openFile(io, path, .{});</span> <span class="line"> ^</span> <span class="line">~/fail/main.zig:6:5: 0x10443db47 in main (fail)</span> <span class="line"> try process_file(io, &quot;data.txt&quot;);</span> <span class="line"> ^</span></code></pre> </figure> <p>The friction is reduced a lot:</p> <ul> <li> No need to come up with any error messages beyond existing variable names. </li> <li> No need to change any of the <code>try</code>s. </li> <li> The context is set per-block. If a function does several fallible operations on a file, the path needs to be specified only once. </li> <li> The context is “telescopic” every function in the call-stack can add its own context. </li> </ul> <p>There’s one huge drawback though — the error message is logged, even if the error is subsequently handled. This is especially important in Zig 0.16, where cancelation (<a href="https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1677r2.pdf">serendipitous-success</a>) is a possible error for any IO-ing operation, and which is intended to be handled, rather than reported.</p> <hr> <p>Generalizing:</p> <ul> <li> Happy path adds context to all operations in-progress. </li> <li> Errors materialize current context. </li> </ul> <p>This does feel like a better error management strategy than decorating errors individually, when they happen. I wonder which language features facilitate this style?</p> <p>This article <a href="https://goldstein.lol/posts/error-progress/" class="display url">https://goldstein.lol/posts/error-progress/</a> rather convincingly argues that the answer might be “none”?</p>
Original Article
View Cached Full Text

Cached at: 05/16/26, 03:33 AM

# Minimal Viable Zig Error Contexts Source: [https://matklad.github.io/2026/05/03/zig-error-context.html](https://matklad.github.io/2026/05/03/zig-error-context.html) May 3, 2026``` fn process_file(io: Io, path: []const u8) !void { errdefer log.err("path={s}", .{path}); const fd = try Io.Dir.cwd().openFile(io, path, .{}); defer fd.close(io); // ... } ``` Out of the box, Zig provides minimal and sufficient facilities for error*handling*—[strongly\-typed error codes](https://matklad.github.io/2025/11/06/error-codes-for-control-flow.html)\. Error*reporting*is left to the user\. Idiomatic solution is to pass a`Diagnostics`out parameter \(“sink”\) to materialize human\-readable strings as needed\. Diagnostics pattern works well for “production” code, but for more script\-y code it adds too much friction relative to the default option of a plain`try fallible\(\)`,which of course gives a less than ideal message on failure: ``` λ zig build error: FileNotFound ~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail) .NOENT => return error.FileNotFound, ^ ~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail) return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options); ^ ~/fail/main.zig:10:16: 0x10443da5f in f (fail) const fd = try Io.Dir.cwd().openFile(io, path, .{}); ^ ~/fail/main.zig:6:5: 0x10443db47 in main (fail) try process_file(io, "data.txt"); ^ ``` Error trace is helpful, but knowing*which*file is the problem is even more so\. The first attempt at finding a middle ground between fully\-fledged diagnostics sink pattern and a plain try is something like this: ``` const fd = dir.openFile(io, path, .{}) catch |err| { log.err("failed to open file '{s}': {t}", .{path, err}); return err; } ``` Unsatisfactory\. The friction is high, you need to come up with a reasonably\-sounding error message, the “happy path” of the code is obscured, and you need to repeat this for every fallible operation\. A worse\-is\-better version of the above code is ``` errdefer log.err("path={s}", .{path}); const fd = try dir.openFile(io, path, .{}); ``` That is, just log error context as`key=value`pairs, guarded by`errdefer`\. The result is not pretty, but passable: ``` λ zig build error: path=./data.txt error: FileNotFound ~/.cache/zig/p/../lib/std/Io/Threaded.zig:4866:35: 0x1044126c7 in dirOpenFilePosix (fail) .NOENT => return error.FileNotFound, ^ ~/.cache/zig/p/../lib/std/Io/Dir.zig:578:5: 0x104347d8b in openFile (fail) return io.vtable.dirOpenFile(io.userdata, dir, sub_path, options); ^ ~/fail/main.zig:10:16: 0x10443da5f in f (fail) const fd = try Io.Dir.cwd().openFile(io, path, .{}); ^ ~/fail/main.zig:6:5: 0x10443db47 in main (fail) try process_file(io, "data.txt"); ^ ``` The friction is reduced a lot: - No need to come up with any error messages beyond existing variable names\. - No need to change any of the`try`s\. - The context is set per\-block\. If a function does several fallible operations on a file, the path needs to be specified only once\. - The context is “telescopic” every function in the call\-stack can add its own context\. There’s one huge drawback though — the error message is logged, even if the error is subsequently handled\. This is especially important in Zig 0\.16, where cancelation \([serendipitous\-success](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p1677r2.pdf)\) is a possible error for any IO\-ing operation, and which is intended to be handled, rather than reported\. --- Generalizing: - Happy path adds context to all operations in\-progress\. - Errors materialize current context\. This does feel like a better error management strategy than decorating errors individually, when they happen\. I wonder which language features facilitate this style? This article[https://goldstein\.lol/posts/error\-progress/](https://goldstein.lol/posts/error-progress/)rather convincingly argues that the answer might be “none”?

Similar Articles

Steering Zig Fmt

Lobsters Hottest

A blog post describing two tips for using `zig fmt` effectively, highlighting its 'steerable' formatting approach where trailing commas and line breaks control layout decisions, and showcasing columnar array formatting.

Zig 0.16.0 release notes: "Juicy Main"

Simon Willison's Blog

Zig 0.16.0 released with a new feature called 'Juicy Main' that provides dependency injection for the main() function, giving convenient access to allocators, IO, environment variables, and CLI arguments.

Zig Builds Are Getting Faster

Mitchell Hashimoto

Zig 0.15 shows significant compile-time improvements over 0.14, with build script compilation dropping from ~7s to ~1.7s and full builds from 41s to 32s, even while still using LLVM. The article highlights progress toward self-hosted backends and incremental compilation.