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

> Learn when to use errors Is versus direct error comparison in Go. Understand wrapped errors and sentinel errors for robust Go error handling.

- Repository: [JetBrains/go-modern-guidelines](https://github.com/jetbrains/go-modern-guidelines)
- Tags: best-practices
- Published: 2026-08-29

---

**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`](https://github.com/JetBrains/go-modern-guidelines/blob/main/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()`

```go
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`](https://github.com/JetBrains/go-modern-guidelines/blob/main/internal/cli/cli.go) file (line 131) demonstrates this pattern when handling the `-h` flag:

```go
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`](https://github.com/JetBrains/go-modern-guidelines/blob/main/internal/cli/cli_test.go) (lines 154-155) validates error identity using `errors.Is` to ensure wrapped failures are caught:

```go
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:

```go
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`](https://github.com/JetBrains/go-modern-guidelines/blob/main/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`](https://github.com/JetBrains/go-modern-guidelines/blob/main/internal/guidelines/guidelines.json) and implements it in [`internal/cli/cli.go`](https://github.com/JetBrains/go-modern-guidelines/blob/main/internal/cli/cli.go) and [`internal/cli/cli_test.go`](https://github.com/JetBrains/go-modern-guidelines/blob/main/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.