How is try/catch, defer, and errdefer compiled?

Is try just a branch? Does try/catch unreachable still generate that branch? Is there anyway to avoid that?

How are these compiled? Are they a function call each? Similar to a goto? In C++ exception handling code (throw/catch) are usually marked cold and to move out of the way. Is the same done with errdefer?

1 Like

try is catch |err| return err;

catch unreachable can optimize the branch to not exists (The most probable)

errdefer will run when the error return branch is reached, catch I think is cold, but there is

if(some_value_error_union) |some_value| {} else |err| {}
which wouldn’t be on cold

errdefer itself I think is just added at the end of the error return paths, and those are cold sometimes, but I don’t know all about the codegen =D

x catch unreachable should not generate any branch in unsafe (ReleaseFast/ReleaseSmall) builds. In unsafe builds, unreachable is a hint to the optimizer that this code paths is guaranteed never to be taken: this allows it to easily eliminate the branch in question. So x catch unreachable would just assume that the value is never an error, unwrapping the payload directly. Of course, in safe builds, unreachable is a safety panic, so there will still be a branch. (Probably in future we’ll automatically mark that branch as unlikely in the optimizer to slightly improve performance of safe builds, but the compiler never really does likeliness annotation for any branch at the minute.)

As an aside, it’s somewhat common to hear this phrased as something like “x catch unreachable asserts that x is not an error”. The term “assert” is (in my experience) generally used a little more precisely in Zig than in some other languages, to mean “will trigger a panic in safe builds, and can be assumed by the optimizer in unsafe builds”. unreachable is our primitive for this, and the standard library function std.debug.assert simply does if (!expected_condition) unreachable;, so you get the optimization advantages!

defer and errdefer, as currently implemented, actually duplicate the deferred expressions at every return path of a function. As such, it’s sometimes worth avoiding using them too much, or particularly with very large expressions. We could, of course, change this in the future to mimic something like the C goto cleanup pattern.


Write your code, paste it into, then you’ll know exactly how it compiles. Yes, sometimes you create a branch to handle errors properly, but if you are expecting errors to not happen the vast majority of the time, then your branch predictor should correctly predict the destination of the branch almost every time.

// try.zig
const TheError = error {

fn failure() !void {
    return TheError.WeBoobed;

pub fn main() !void {
    try failure();


/opt/zig-0.11/zig build-exe try.zig -femit-asm -O ReleaseFast -fstrip -fsingle-threaded

Then look into try.s

1 Like

Yes it should have 100% prediction (and on the times there is an error, we don’t care). But that still fills up the branch prediction and branch target prediction hardware that would be better of unused and cause other branches to not predict correctly possibly. It will also add small pressure to the icache and if in a loop will likely kill the Loop Stream Decoder optimization.