When to Use `errors.Is` Versus Direct Error Comparison in Go

Use errors.Is(err, target) whenever the error might be wrapped with additional context, and reserve direct comparison (err == target) only for simple sentinel errors that are guaranteed never to be wrapped.

Go’s error handling architecture distinguishes between exact pointer equality and semantic error identity. According to the JetBrains/go-modern-guidelines repository, choosing the wrong comparison method causes subtle bugs when errors travel through layers of abstraction that add context via fmt.Errorf("%w", err) or custom wrappers.

The Fundamental Difference

Go errors form a chain through the Unwrap() method. Understanding how comparison operators traverse this chain determines which technique to use.

Direct comparison (err == target) validates only pointer or value equality. It returns true exclusively when the error variable you hold is exactly the same memory address or constant value as the target. According to the guidelines in internal/guidelines/guidelines.json (lines 1281-1285), this fails silently when an error has been wrapped because wrapping creates a new error value containing the original.

errors.Is(err, target) walks the entire error chain. It recursively calls Unwrap() on each error layer and also respects custom Is methods defined by error types. This allows it to match sentinel values even when they are buried beneath multiple wrapping layers.

When to Use Direct Comparison (==)

Reserve the equality operator for unwrapped sentinel errors defined and controlled entirely within your package scope.

This approach works correctly only when you can guarantee that:

  • The error is returned directly without decoration
  • No intermediate function adds context using %w formatting verbs
  • The error is a package-level variable initialized with errors.New()
var ErrNotFound = errors.New("not found")

func find(id int) error {
    // Returns the sentinel directly, never wrapped
    return ErrNotFound
}

if err := find(42); err == ErrNotFound {
    // Safe only because find() never wraps the error
}

When to Use errors.Is

Use errors.Is for any error that originates from external libraries, standard library packages like os, or internal functions that might wrap errors to provide context.

The internal/cli/cli.go file (line 131) demonstrates this pattern when handling the -h flag:

if errors.Is(err, flag.ErrHelp) {
    // Handles flag.ErrHelp even if wrapped with additional context
}

Similarly, the test suite in internal/cli/cli_test.go (lines 154-155) validates error identity using errors.Is to ensure wrapped failures are caught:

err := Run(tt.args, failingWriter{})
if !errors.Is(err, errWriteFailed) {
    t.Fatalf("Run() error = %v, want %v", err, errWriteFailed)
}

Handling Wrapped Errors Correctly

Wrapped errors occur when you augment an error with additional information before returning it up the call stack. The errors.Is function handles these scenarios by unwrapping layers automatically.

Consider a function that wraps ErrNotFound with context:

func findWrapped(id int) error {
    // Creates a new error wrapping ErrNotFound
    return fmt.Errorf("search failed for id %d: %w", id, ErrNotFound)
}

if err := findWrapped(42); err == ErrNotFound {
    // Fails: err is not the same pointer as ErrNotFound
}

if err := findWrapped(42); errors.Is(err, ErrNotFound) {
    // Succeeds: errors.Is unwraps the chain and finds ErrNotFound
}

Official Guidelines from JetBrains

The canonical rule is explicitly defined in internal/guidelines/guidelines.json:

"Use errors.Is(err, target) instead of err == target so wrapped errors are handled correctly."

This guideline reflects the modern Go standard where errors are routinely wrapped to preserve stack traces and context. Direct comparison violates the principle of error identity because it conflates the container (the wrapper) with the content (the underlying error).

Summary

  • Use errors.Is for all errors that might pass through functions adding context, including standard library errors (os.ErrNotExist, io.EOF, etc.) and errors from third-party libraries.
  • Use direct comparison only for private sentinel errors within a single package that are guaranteed never to be wrapped.
  • Reference the source: The JetBrains/go-modern-guidelines repository enforces this distinction in internal/guidelines/guidelines.json and implements it in internal/cli/cli.go and internal/cli/cli_test.go.
  • Remember unwrapping: errors.Is traverses the error chain via Unwrap(), while == checks only the top-level error value.

Frequently Asked Questions

Can errors.Is replace all direct error comparisons?

Yes, technically errors.Is(err, target) works correctly even for unwrapped errors, making it the safer default choice. However, direct comparison (err == target) is slightly more performant and semantically clearer when you explicitly control the error lifecycle and know wrapping is impossible.

What happens if an error type defines a custom Is method?

The errors.Is function first checks if the error type implements an Is(target error) bool method. If present, errors.Is invokes this method to determine equality. This allows complex error types to define their own identity logic beyond simple pointer comparison, such as matching error codes across different wrapped instances.

Why does err == io.EOF fail sometimes but errors.Is(err, io.EOF) works?

The standard library frequently wraps errors to add context. For example, bufio.Reader might wrap io.EOF with buffer state information. Direct comparison fails because the returned error is a distinct value containing io.EOF, not io.EOF itself. errors.Is unwraps the buffer error and discovers the underlying io.EOF sentinel.

Is there a performance penalty for using errors.Is?

There is minimal overhead. errors.Is performs a short loop calling Unwrap() until it finds a match or reaches the end of the chain. For shallow error chains (1-3 levels), this overhead is negligible compared to I/O operations or business logic. Use it unless profiling identifies a hot path where direct comparison is proven safe and necessary.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →