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

Returning to Zig

Lobsters Hottest

The author describes their journey from Zig to Rust and back to Zig, exploring the trade-offs between stability and expressiveness in programming languages.

Golfing Zig ELF Binaries (2025)

Lobsters Hottest

A technical deep-dive into reducing the size of Zig ELF binaries, starting from 2180K to under 500 bytes by stripping debug info, switching to ReleaseSmall, and using a freestanding target.

Zig by Example

Lobsters Hottest

Zig by Example is a hands-on introduction to the Zig programming language, featuring annotated example programs for version 0.16.

Zig by Example

Hacker News Top

A hands-on introduction to the Zig programming language via annotated examples, covering basic to advanced topics. Inspired by Go by Example.