Why Go Chose Explicit Errors: A Deep Dive into Simplicity, Clarity, and Control
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()
anderrors.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
, andrecover
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.”
Comments ()