How to Avoid Go's Subtle Pointer vs. Value Error Handling Bugs

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?

Spoiler:

Yes, it did! The old type switch (case CriticalAddonsOnlyError:) would catch CriticalAddonsOnlyError when it was returned as a value.

The new 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.

The 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.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.

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.As calls 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.