Imagine you have to rewrite cluster autoscaling. There’s a function makeNodeSchedulable
that rarely returns a
CriticalAddonsOnlyError
when that node has a CriticalAddonsOnly
taint.
The old code looks like this:
makeSchedulableLoop:
for start := time.Now(); time.Since(start) < makeSchedulableTimeout; time.Sleep(makeSchedulableDelay) {
err := makeNodeSchedulable()
switch err.(type) {
case CriticalAddonsOnlyError:
continue makeSchedulableLoop
default:
}
break
}
You modernize it to Go 1.13-style error handling1 to take advantage of error wrapping and more robust error checking like this:
makeSchedulableLoop:
for start := time.Now(); time.Since(start) < makeSchedulableTimeout; time.Sleep(makeSchedulableDelay) {
err := makeNodeSchedulable()
var criticalAddonsOnlyErrorType *CriticalAddonsOnlyError
if err != nil && errors.As(err, &criticalAddonsOnlyErrorType) {
continue makeSchedulableLoop
}
break
}
Did the behavior change? Yes, it did! The old type switch ( The new The Spoiler:
case CriticalAddonsOnlyError:
) would catch CriticalAddonsOnlyError
when it was
returned as a value.errors.As
check, however, matches if an error in the chain is of pointer type (*CriticalAddonsOnlyError
).
Since makeNodeSchedulable
returns the error as a value, the new code will fail to catch it, silently changing the
program’s behavior.errors.As
function requires a pointer to a variable of the target error type as its second argument so it can set
that variable to the matching error in the chain – this is why we pass &target
. The confusion arises when the error
itself is also a pointer.
You can run the code yourself on the Go Playground.
This type of bug is particularly dangerous because it fails silently – our code compiles and runs, but behaves differently than expected. In production systems, this could mean critical error handling paths are bypassed.
To understand why this subtle bug occurred and how to prevent it, let’s first examine Go’s error handling evolution.
Errors in Go are conventionally returned as the last result and have the predefined interface type
error
. Unlike most other return values in Go functions (which are typically concrete
types2), the error result is an interface, while returning nil
indicates “no error”3. A key principle is that
“errors are values”4.
Since Go 1.131, errors.Is
and errors.As
are
preferred over direct comparisons and
type assertions.
Building on this foundation, Go errors typically follow these patterns5:
Sentinel Errors, conventionally starting with Err...
:
var ErrSomething = errors.New("something")
Structured Errors, conventionally ending with ...Error
:
type SomethingError struct{ ... }
Sometimes you need to examine the structured data to better handle the situation or provide useful help, like getting
the position from a json.SyntaxError
or the key length from an
aes.KeySizeError
:
err := json.Unmarshal([]byte("{.}"), &v)
var syn *json.SyntaxError
if errors.As(err, &syn) {
fmt.Printf("Syntax error at position %d: %v.\n", syn.Offset, err)
}
_, err := aes.NewCipher([]byte("secret"))
var kse aes.KeySizeError
if errors.As(err, &kse) {
fmt.Printf("AES keys must be 16, 24 or 32 bytes long, got %d bytes.\n", kse)
}
Notice how json.SyntaxError
is used as a pointer (*json.SyntaxError
) while aes.KeySizeError
is used as a value –
this reflects their intended usage patterns from the standard library.
This foundation explains why the modernization bug occurred -— let’s examine the specific issue that caused it.
While you should “document […] error types that your functions return to callers …6”, in practice this documentation is often missing.
Due to Go’s method set rules, if an error type T
defines an Error() string
method with a value receiver, both the value type T
and the pointer type *T
satisfy the error
interface.
type SomeError struct{ N int }
func (s SomeError) Error() string { return "some error " + strconv.Itoa(int(s.N)) }
func F1() error { return SomeError{1} } // Returns SomeError as a VALUE
func F2() error { return &SomeError{2} } // Returns SomeError as a POINTER
But for type assertions and errors.As
, you must match the exact dynamic type –
the (non-interface) type assigned to the error at run time – pointer or value:
err := F1()
var target *SomeError // We're looking for a POINTER
if errors.As(err, &target) { /* This will NOT match because F1() returns a value, not a pointer */ }
Try it on the Go Playground.
🔑 Key Insight:
errors.As
requires exact type matching of the error’s dynamic type. If the error is returned as a value but you check for a pointer, the check will silently fail – no compilation error, no runtime panic, just wrong behavior.
This mismatch is a frequent source of discussion7 8.
While some argue that all errors should have pointer receivers9, a review of the standard library and golang.org/x
packages shows a mixed practice. Most (but not all) struct-based errors use pointer receivers, while non-struct types
often use value receivers. Changing existing APIs would break substantial codebases.
Before moving on, ask yourself some questions about your current codebase:
errors.As
calls match the error’s return type?Here’s a practical two-step approach I propose to prevent these issues:
with a compile-time interface assertion in the defining package:
// MyValueError is intended to be used as a value.
var _ error = MyValueError{}
// MyPointerError is intended to be used as a pointer.
var _ error = (*MyPointerError)(nil)
This is cheap, unambiguous, and easier to rely on than comments. It clearly documents your intent for other developers without affecting your existing code base.
like errortype
that picks up this pattern to warn when an error is used
with the wrong shape (pointer vs. value) in returns, type assertions, type switches, and errors.As
-like functions.
It’s that simple – and effective.
Prefer this errors.As
pattern:
// Preferred - clear variable declaration makes intent obvious
var target SomeError
if errors.As(err, &target) { /* ... */ }
Instead of:
if errors.As(err, &SomeError{}) { /* ... */ }
if e := &(SomeError{}); errors.As(err, e) { /* ... */ }
if errors.As(err, new(SomeError)) { /* ... */ }
While the latter forms may be shorter, they are harder to read (can you spot that a value error is expected?) and are more likely to confuse readers or linters.
Return errors in their intended form (pointer or value). If you provide constructors for your error types, ensure they return the correct shape.
Furthermore, don’t accept both pointer and value types for the same error in your API. While you could argue this is a good application of Postel’s law, in practice it shifts bugs to rarely used parts of your code, causing hard-to-find errors in corner cases. It is better to enforce consistent usage and fail fast than to have lingering bugs.
Avoid implementing complex Is
and As
methods on your error types. They were envisioned for very narrow purposes
(“In the simplest case, the errors.Is
function behaves like a comparison to a sentinel error, and the errors.As
function behaves like a type assertion.1”); doing more risks confusing the casual user (who might be one of your
colleagues). Especially comparisons with new literals (errors.Is(&SomeError{}, &SomeError{})
) should always be
false
10.
Implement zero-sized errors as values, as using pointers to zero-sized types introduces subtle equality issues11.
Use mostly pointer receivers for non-empty structs, value receivers for empty structs and non-struct types. There will
be exceptions to this generalization, but documenting intent and enforcing consistency – e.g., with
errortype
– greatly reduces confusion around errors.As
and type
assertions.
The subtle distinction between pointer and value error types can silently break your code. The two-step approach outlined above – documenting intent with compile-time assertions and using linting tools – provides a practical foundation for preventing these issues.
The example at the beginning (inspired by Kubernetes) demonstrates how this distinction can change program behavior without obvious symptoms, making it particularly dangerous in production systems where critical error handling paths might be bypassed.
By establishing clear patterns early and enforcing them consistently, you can avoid the type of silent failure that plagues some Go codebases.
Go Blog: Examining errors with Is and As ↩︎ ↩︎ ↩︎
Go Code Review Comments: Interfaces ↩︎
Rob Pike: Errors are values ↩︎
Matt T. Proud: Go: Naming Errors ↩︎
Google Go Style Best Practices: Errors ↩︎
Reddit: Seeking Advice on Custom Error Types: Value vs. Pointer Receiver ↩︎
golang-nuts: Using errors.As error-prone ↩︎
cmplint: The Problem ↩︎