Understanding Go Error Types: Pointer vs. Value
Series:
A Small Story…
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.
Background: Go’s Error History
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.
The Core Problem: Type Matching Requirements
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.Asrequires 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.
Quick Self-Check
Before moving on, ask yourself some questions about your current codebase:
- Can you list the custom error types in your current project and whether they’re intended as values or pointers?
- Are your error types’ intended usage patterns clear to other developers?
- In code reviews, do you verify that
errors.Ascalls match the error’s return type? - When designing new error types, do you explicitly choose between pointer and value receivers?
The Solution: Document Intent and Enforce Consistency
Here’s a practical two-step approach I propose to prevent these issues:
Step 1: Declare the Intended Usage
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.
Step 2: Use a Linter
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.
Additional Recommendations
Prefer Clear Variable Declarations
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.
Enforce Consistency
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.
Keep Error Methods Simple
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
false10.
Handle Zero-Sized Errors Carefully
Implement zero-sized errors as values, as using pointers to zero-sized types introduces subtle equality issues11.
Have Sensible Defaults
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.
Conclusion
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.
Working with Errors in Go 1.13: 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 ↩︎