Skip to content

Unreachable Code

Some parts of a program should never run.

Some parts of a program should never run.

For example:

switch (status) {
    .ok => handleOk(),
    .failed => handleFailed(),
}

If every possible status value is handled, there should be no unknown case.

But sometimes you need to tell Zig:

this point should be impossible to reach.

Zig gives you unreachable for that.

unreachable;

unreachable means: if the program reaches this point, something is wrong.

A First Example

const std = @import("std");

pub fn main() void {
    const x = 10;

    if (x > 0) {
        std.debug.print("positive\n", .{});
    } else {
        unreachable;
    }
}

This prints:

positive

The else branch says:

unreachable;

That means the programmer believes this branch cannot happen.

In this exact program, x is always 10, so the else branch really is impossible.

unreachable Is a Promise

unreachable is not just a comment.

It is a promise to the compiler.

You are saying:

this code path cannot happen.

That promise matters. In safe build modes, reaching unreachable causes a runtime safety panic. In optimized builds, the compiler may assume it cannot happen and use that assumption for optimization.

So you should not use unreachable casually.

This is dangerous:

fn printPositive(x: i32) void {
    if (x > 0) {
        std.debug.print("{}\n", .{x});
    } else {
        unreachable;
    }
}

The function name says printPositive, but the parameter type is still i32. A caller can pass 0 or -5.

printPositive(-5);

Then the supposedly impossible branch is reached.

The better version checks the input properly:

fn printPositive(x: i32) void {
    if (x <= 0) {
        std.debug.print("not positive\n", .{});
        return;
    }

    std.debug.print("{}\n", .{x});
}

Use unreachable only when the program logic truly makes a path impossible.

unreachable After an Infinite Loop

Sometimes a function has a return type, but the code never returns normally.

fn runForever() noreturn {
    while (true) {
        // keep running
    }
}

The return type noreturn means this function never returns to its caller.

A simpler example:

const std = @import("std");

fn crash() noreturn {
    @panic("stop");
}

pub fn main() void {
    crash();
}

@panic does not return. It stops the program.

unreachable also has type noreturn. That means it fits anywhere a value is expected, because execution never continues past it.

unreachable in switch

One common place to see unreachable is in a switch.

Example:

const std = @import("std");

fn digitName(n: u8) []const u8 {
    return switch (n) {
        0 => "zero",
        1 => "one",
        2 => "two",
        3 => "three",
        4 => "four",
        5 => "five",
        6 => "six",
        7 => "seven",
        8 => "eight",
        9 => "nine",
        else => unreachable,
    };
}

pub fn main() void {
    std.debug.print("{s}\n", .{digitName(3)});
}

This prints:

three

But this function is not fully safe:

digitName(99);

The type u8 allows values from 0 to 255. So the else branch can happen.

A better design is to use a smaller type or handle invalid input.

fn digitName(n: u8) []const u8 {
    return switch (n) {
        0 => "zero",
        1 => "one",
        2 => "two",
        3 => "three",
        4 => "four",
        5 => "five",
        6 => "six",
        7 => "seven",
        8 => "eight",
        9 => "nine",
        else => "not a digit",
    };
}

Now the function handles all valid u8 inputs.

Better: Make Impossible States Impossible

Instead of using unreachable, often you should design the type better.

For example, this function accepts any u8:

fn digitName(n: u8) []const u8 {
    return switch (n) {
        0 => "zero",
        1 => "one",
        2 => "two",
        3 => "three",
        4 => "four",
        5 => "five",
        6 => "six",
        7 => "seven",
        8 => "eight",
        9 => "nine",
        else => unreachable,
    };
}

But only ten values are meaningful.

An enum can model the valid cases directly:

const Digit = enum {
    zero,
    one,
    two,
    three,
    four,
    five,
    six,
    seven,
    eight,
    nine,
};

fn digitName(digit: Digit) []const u8 {
    return switch (digit) {
        .zero => "zero",
        .one => "one",
        .two => "two",
        .three => "three",
        .four => "four",
        .five => "five",
        .six => "six",
        .seven => "seven",
        .eight => "eight",
        .nine => "nine",
    };
}

Now there is no invalid digit value.

The switch is exhaustive. No else is needed.

This is better Zig design: do not accept impossible states when the type system can prevent them.

unreachable and Exhaustive Logic

Sometimes unreachable appears after a loop that should always return.

fn findFirstEven(numbers: []const u8) u8 {
    for (numbers) |n| {
        if (n % 2 == 0) {
            return n;
        }
    }

    unreachable;
}

This function says:

there is always at least one even number.

But the type does not prove that.

A caller can pass:

&[_]u8{ 1, 3, 5 }

Then the loop finishes and reaches unreachable.

A better function returns an optional:

fn findFirstEven(numbers: []const u8) ?u8 {
    for (numbers) |n| {
        if (n % 2 == 0) {
            return n;
        }
    }

    return null;
}

The return type ?u8 says:

this function may return a number, or it may return nothing.

That is clearer and safer.

unreachable for Proven Impossible Branches

There are cases where unreachable is reasonable.

Suppose a value has already been checked.

const std = @import("std");

fn divideByNonZero(a: i32, b: i32) i32 {
    if (b == 0) {
        @panic("b must not be zero");
    }

    if (b != 0) {
        return @divTrunc(a, b);
    } else {
        unreachable;
    }
}

pub fn main() void {
    std.debug.print("{}\n", .{divideByNonZero(10, 2)});
}

After this check:

if (b == 0) {
    @panic("b must not be zero");
}

the later else branch should be impossible.

Even here, the code can be simpler:

fn divideByNonZero(a: i32, b: i32) i32 {
    if (b == 0) {
        @panic("b must not be zero");
    }

    return @divTrunc(a, b);
}

The simpler version is better. It avoids needing unreachable at all.

unreachable in Low-Level Code

Low-level Zig code sometimes uses unreachable when dealing with things outside Zig’s type system:

C APIs

hardware registers

system calls

compiler internals

manual state machines

For example, a C library may return integer codes:

const Code = enum(i32) {
    ok = 0,
    failed = 1,
};

If an external function is documented to return only 0 or 1, you may see code that treats other values as impossible.

fn decode(code: i32) Code {
    return switch (code) {
        0 => .ok,
        1 => .failed,
        else => unreachable,
    };
}

But be careful. External systems can violate expectations. In many cases, returning an error is safer:

const DecodeError = error{
    UnknownCode,
};

fn decode(code: i32) DecodeError!Code {
    return switch (code) {
        0 => .ok,
        1 => .failed,
        else => error.UnknownCode,
    };
}

This version admits reality: external input may be invalid.

unreachable as Documentation

unreachable documents an invariant.

An invariant is a rule that should always be true at a certain point in the program.

Example:

if (state == .closed) {
    unreachable;
}

This says:

at this point, state should never be .closed.

But unreachable is stronger than a comment. It changes program behavior if the invariant is broken.

If the invariant can be broken by user input, file contents, network data, or external APIs, do not use unreachable. Handle the case.

Use unreachable for internal logic that truly cannot happen when the program is correct.

unreachable vs @panic

Both can stop the program.

unreachable;

means:

this code path is impossible.

@panic("message");

means:

this code path is possible, but it is a fatal error.

Use @panic when you want to report a deliberate fatal failure.

Use unreachable when reaching the code means the program’s internal reasoning is broken.

Example:

if (config_is_invalid) {
    @panic("invalid hard-coded config");
}

This is a panic because the case is possible, but fatal.

Example:

switch (mode) {
    .read => read(),
    .write => write(),
}

No unreachable is needed when the switch is already exhaustive.

Avoid Using unreachable to Silence the Compiler

A bad use of unreachable is forcing code to compile when the type design is wrong.

Bad:

fn getFirst(items: []const u8) u8 {
    if (items.len > 0) {
        return items[0];
    }

    unreachable;
}

The function accepts an empty slice, so the empty case is reachable.

Better:

fn getFirst(items: []const u8) ?u8 {
    if (items.len > 0) {
        return items[0];
    }

    return null;
}

Or, if empty input is a programmer error, make that clear:

fn getFirstNonEmpty(items: []const u8) u8 {
    std.debug.assert(items.len > 0);
    return items[0];
}

The name and assertion communicate the requirement.

The Main Idea

unreachable marks a code path that should be impossible.

It is useful, but sharp.

Use it when the program logic or type system has already ruled out a path.

Do not use it for bad input, missing files, network errors, invalid user data, or normal failure cases.

The beginner rule is simple:

prefer better types, explicit errors, optionals, or assertions.

Use unreachable only when reaching that line would mean the program itself is wrong.