2026-02-217 min read

Why Go Can't Try

Go's refusal to add a try keyword isn't about loving boilerplate.

GoZigError HandlingProgramming Languages

Every Go developer has written this code thousands of times:

data, err := os.ReadFile(path)
if err != nil {
    return nil, err
}

And every Go developer has at some point looked at Zig or Rust and felt a pang of envy:

const data = try readFile(path);

One line. Clean. Honest. So why doesn't Go just do this? The common answer is "the Go team likes explicitness." That's true, but it's not the whole story. The real answer runs much deeper.


Zig Is Actually More Explicit Than Go

Here's the irony nobody talks about: Zig is more explicit about errors than Go, not less.

In Zig, a function's return type tells you everything before you read a single line of its body. !Config means this function can fail. The compiler knows every possible error it can return. If you don't handle a case, it won't compile. try isn't hiding anything, it's saying clearly "I see this can fail, and I'm intentionally propagating it upward."

Go's if err != nil, on the other hand, is not actually enforced by the language. This compiles just fine:

data, _ := os.ReadFile(path)

So Go's celebrated explicitness is partly an illusion. The pattern is verbose but the compiler doesn't require you to actually handle anything. Zig's compiler does. On the question of which language truly enforces explicit error handling, Zig wins.


So Why Not Just Add try?

At first glance it seems simple. try isn't a keyword in Go today, so adding it wouldn't break existing code. A function like this:

func loadConfig(path string) (Config, error) {
    data   := try os.ReadFile(path)
    raw    := try parseJSON(data)
    config := try validate(raw)
    return config, nil
}

looks like a clear improvement. Less noise, same semantics. The Go team's official objection is that try creates invisible exit points: every try call is a potential early return that you can miss when reading code quickly. They've historically drawn a line between this and exceptions, arguing both make control flow hard to follow.

It's a reasonable point. But it's not the real reason try hasn't landed.


The Real Problem Is Deeper: Go's Error Type

To understand why, you have to look at what error actually is in Go:

type error interface {
    Error() string
}

That's the entire definition. Any type with an Error() string method is a valid error. This means errors in Go carry arbitrary unstructured information, every package invents its own error types, and the compiler has absolutely no idea what errors a function might return. The ecosystem built on top of this (errors.Is(), errors.As(), fmt.Errorf("%w")) is entirely a set of runtime conventions, not compile-time guarantees.

Zig's error sets are the opposite. They are finite, compiler-known, and exhaustively checkable:

const ConfigError = error{ FileNotFound, ParseFailed, InvalidInput };

The compiler can infer the union of all possible errors across an entire call chain automatically. It can tell you when you've missed a case. This is a fundamentally different and more powerful model.

That power comes with a hard constraint. A Zig error is not a struct or pointer: it is a globally unique, 16-bit integer. The compiler maps every error.FileNotFound across your codebase and every library you depend on to the same integer ID. Zero overhead, zero heap allocations.

But because it's just a number, it cannot carry a payload. Go makes it trivial to attach context to a failing call:

func loadEverything() error {
    if err := loadConfig(); err != nil {
        return fmt.Errorf("failed to load config: %w", err)
    }
    if err := loadData(); err != nil {
        return fmt.Errorf("failed to load data: %w", err)
    }
    return nil
}

In Zig, there's no equivalent. If both calls fail with error.FileNotFound, the error value alone can't tell you which file was missing. Zig's answer is the Error Return Trace: instead of enriching the error value, the compiler tracks the error's path automatically. In Debug or ReleaseSafe mode, an unhandled error points you directly to the failing try:

error: FileNotFound
/src/main.zig:8:5: 0x104b2a61b in loadEverything (main)
    try loadConfig();
    ^
/src/main.zig:2:5: 0x104b2a5d7 in main (main)
    try loadEverything();
    ^

It tells you where the error traveled, not what it means. Rather than enriching the error value, Zig enriches the tooling.

Here's the uncomfortable truth: a try keyword in Go without fixing the error type is just syntax sugar. You'd get slightly less typing but none of the real benefits - no exhaustiveness checking, no compiler-inferred error sets, no guarantee that you've actually handled every case. It would make control flow less visible while delivering only a fraction of what makes Zig's approach genuinely good.


Why Fixing the Error Type Is Impossible

So why not fix error too? Because it would break everything.

Go's error interface is not just in the standard library - it is the standard library. Every function in os, io, net, database/sql, and the rest returns this interface. Millions of lines of production Go code are built around the assumption that errors are opaque interface values you inspect at runtime.

If Go introduced typed error sets tomorrow, os.ReadFile would need to return something like os.PathError | io.EOF instead of plain error. Every single caller in existence would break. All the errors.Is() and errors.As() machinery would become either redundant or need a complete redesign. Third-party libraries would be split between old and new styles for a decade. The standard library itself would feel like a legacy API overnight.

This isn't a small migration. This is rewriting the foundation of the language while the building is occupied.


What the Go Team Is Really Saying

When the Go team argues against try on explicitness grounds, I think they're defending a deeper position they rarely state directly:

You can't get the real benefits of Zig-style error handling without a fundamental redesign of the error type. A redesign of the error type breaks every Go program ever written. Therefore, try syntax alone is a half-measure that costs you readability while giving you little of real value in return.

That's actually a coherent and defensible position. It's just not the argument they usually make publicly.


The Honest Takeaway

Go and Zig made different foundational choices about errors early on, and those choices compound over time. Zig built errors into the type system from the start: exhaustive, zero-cost, but incapable of carrying context. Go made them a flexible interface: expressive and wrappable, but with no compiler enforcement. Both decisions made sense at the time. But they lead to very different places.

Go isn't refusing try because its designers love boilerplate. It's refusing try because the one change that would make it genuinely worthwhile; a typed, compiler-aware error system is the one change it can never make.

The if err != nil pattern isn't going anywhere. Not because Go can't add a keyword, but because the error system underneath it is too deeply woven into everything Go has ever built to change now.

That's the real reason Go can't try.