Why Go Chose Explicit Errors: A Deep Dive into Simplicity, Clarity, and Control

Why Go Chose Explicit Errors: A Deep Dive into Simplicity, Clarity, and Control
Photo by Queslei Jonas Dos Santos Oliveira / Unsplash

One of the most debated—and arguably most misunderstood—aspects of Go (Golang) is its approach to error handling. Unlike many modern programming languages that lean on exceptions or implicit global error states, Go makes a deliberate choice: every error must be handled explicitly. This decision is not accidental; it’s a core part of Go's identity. But why was this choice made, and how does it compare to traditional error handling mechanisms in other languages like C, Java, or Python?

Let’s unpack it.


Go’s Philosophy: Make Errors First-Class Citizens

In Go, errors are just values—typically returned as the last result of a function. Developers are encouraged, even required, to check for errors explicitly right after calling a function.

data, err := os.ReadFile("file.txt")
if err != nil {
    log.Fatal(err)
}

This approach ensures that:

  • Nothing is hidden.
  • Control flow is predictable.
  • Responsibility is explicit, not deferred.

It might feel verbose at first, especially if you come from exception-heavy languages, but this verbosity serves a purpose: readability, clarity, and robustness.


The C Approach: Global Implicit Errors (errno)

In C, many standard library functions indicate failure by returning a sentinel value (e.g., -1, NULL) and setting a global variable errno to specify what went wrong.

FILE *f = fopen("file.txt", "r");
if (f == NULL) {
    perror("fopen");
}

This is explicit checking of an implicit result. The developer must know the function might fail, must check the return value, and must then consult errno to understand the error.

While common in C, this approach has major drawbacks:

  • The global errno is not thread-safe by default.
  • It’s easy to forget to check the error.
  • You have to remember which functions set errno and under what conditions.

The Exception Model: Implicit All the Way

Languages like Java, C++, C#, Python, and Ruby popularized exceptions as the primary way to handle errors.

try {
    readFile("file.txt");
} catch (IOException e) {
    System.out.println(e.getMessage());
}

This model has a very different feel:

  • Error reporting is implicit: no error is part of the return signature.
  • Error checking is also implicit: if you don’t use try/catch, the program may crash or bubble the error up the call stack.

While convenient for many use cases, exceptions introduce hidden control flows, and:

  • Can be overused or misused, leading to catch-all blocks that silence important errors.
  • Obscure what functions can fail, making code harder to reason about.
  • Make code paths non-linear, which is a debugging nightmare in systems-level code.

Why Go Took the “Harder” Road

The creators of Go, having come from decades of experience building large-scale systems (notably at Google), intentionally avoided exceptions. Their goal was simplicity, and more importantly, predictability.

Some key motivations:

  • Errors should not surprise you.
  • Developers must take responsibility.
  • Readability is more valuable than convenience.

By forcing developers to deal with errors up front, Go code becomes:

  • More honest (no hidden surprises).
  • Easier to debug (you always know where errors are checked).
  • Easier to audit (errors are visible in every function that can fail).

This makes Go particularly strong for building robust infrastructure, where silent failures are unacceptable.


Criticism and Rebuttals

Yes, Go's error handling is verbose. And yes, the community has expressed some fatigue from writing repetitive error checks. But Go has responded thoughtfully:

  • Go 1.13 introduced errors.Is() and errors.As() for structured error matching.
  • Community packages like github.com/pkg/errors added stack traces and wrapping.
  • Proposals for try-like syntax were rejected to avoid reintroducing implicit flows.
  • Tools like errgroup, defer, and recover allow for sophisticated but still explicit error handling in concurrent and panic-prone code.

The takeaway? Go could adopt exceptions. It chose not to—on purpose.


Other Considerations

Error Composition

In Go, you can wrap errors using fmt.Errorf or the standard errors package, giving you rich context:

return fmt.Errorf("could not open config: %w", err)

Panic and Recover

Go does have exceptions—but only for truly exceptional states (bugs, not expected errors):

defer func() {
    if r := recover(); r != nil {
        fmt.Println("Recovered from panic:", r)
    }
}()
panic("something went really wrong")

This is equivalent to saying: you can crash—but only on purpose, and only when appropriate.

Team Communication

When reading Go code:

  • You can immediately see which functions might fail.
  • You know the caller is handling it, or at least made a conscious decision to ignore it (_).
  • Linters and static analyzers can enforce best practices.

This makes Go great for team collaboration, where consistency and clarity matter more than syntactic sugar.


Finally: Clarity Over Cleverness

Go’s decision to use explicit error values and explicit checks may seem old-fashioned, especially in an era of "modern" exception-driven languages. But that’s exactly the point. Go favors clarity over cleverness, explicit over implicit, and predictability over magic.

It may take a little more typing, but it saves hours of debugging and head-scratching later on.

In the words of Rob Pike, one of Go’s creators:

“Errors are just values. They have type, and they are part of the normal flow of the program.”

Support Us