TL;DR

  • Exported concrete error types are superior to sentinel errors. They can be more performant, cannot be clobbered, and promote extensibility.
  • Third-party function errutil.Find is a powerful alternative to standard-library function errors.As.

Setting the scene

Imagine that you’re writing a package named bluesky whose purpose is to check the availability of usernames on Bluesky, the up-and-coming social-media platform:

package bluesky

func IsAvailable(username string) (bool, error) {
  // actual implementation omitted
  return false, nil
}

Calls to IsAvailable may fail (i.e. return a non-nil error value) for various reasons: username may not be valid on Bluesky; or there may be technical difficulties that prevent the function from determining username’s availability on Bluesky. You anticipate that clients of your package will wish to programmatically react to function IsAvailable’s various failure cases, and you intend to design your package to allow them to do so.

Sentinel errors

The most popular and most straightforward approach consists in exporting distinguished error variables, a.k.a. sentinel errors:

package bluesky

import (
  "errors"
  "math/rand/v2"
)

var ErrInvalidUsername = errors.New("invalid username")

var ErrUnknownAvailability = errors.New("unknown availability")

func IsAvailable(username string) (bool, error) {
  // actual implementation omitted
  switch rand.IntN(3) {
  case 0:
    return false, ErrInvalidUsername
  case 1:
    return false, ErrUnknownAvailability
  default:
    return false, nil
  }
}

Here is an example of client code reacting to such sentinel errors:

package main

import (
  "errors"
  "fmt"
  "os"
  "strconv"

  "example.com/bluesky"
)

func main() {
  if len(os.Args) < 2 {
    fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
    os.Exit(1)
  }
  username := os.Args[1]
  avail, err := bluesky.IsAvailable(username)
  if errors.Is(err, bluesky.ErrInvalidUsername) {
    const tmpl = "%q is not valid on Bluesky.\n",
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if errors.Is(err, bluesky.ErrUnknownAvailability) {
    const tmpl = "The availability of %q on Bluesky could not be checked.\n",
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
  fmt.Println(strconv.FormatBool(avail))
}

Concrete error types

Here is another possible approach: for each distinguished failure case, export one concrete error type based on an empty struct and equipped with an Error method that uses a pointer receiver.

package bluesky

import "math/rand/v2"

type InvalidUsernameError struct{}

func (*InvalidUsernameError) Error() string {
  return "invalid username"
}

var errInvalidUsername = new(InvalidUsernameError)

type UnknownAvailabilityError struct{}

func (*UnknownAvailabilityError) Error() string {
  return "unknown availability"
}

var errUnknownAvailability = new(UnknownAvailabilityError)

func IsAvailable(username string) (bool, error) {
  // actual implementation omitted
  switch rand.IntN(3) {
  case 0:
    return false, errInvalidUsername
  case 1:
    return false, errUnknownAvailability
  default:
    return false, nil
  }
}

Edit (2025-04-01): By some oversight on my part, my dummy implementation of IsAvailable used to incur allocations. Thanks to people on Reddit for pointing out this blunder to me. IsAvailable is now allocation-free.


Here is an example of client code reacting to such concrete error types:

package main

import (
  "errors"
  "fmt"
  "os"
  "strconv"

  "example.com/bluesky"
)

func main() {
  if len(os.Args) < 2 {
    fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
    os.Exit(1)
  }
  username := os.Args[1]
  avail, err := bluesky.IsAvailable(username)
  var iuerr *bluesky.InvalidUsernameError
  if errors.As(err, &iuerr) {
    const tmpl = "%q is not valid on Bluesky.\n",
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  var uaerr *bluesky.UnknownAvailabilityError
  if errors.As(err, &uaerr) {
    const tmpl = "The availability of %q on Bluesky could not be checked.\n",
    fmt.Fprintf(os.Stderr, tmpl, username)
    os.Exit(1)
  }
  if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
  fmt.Println(strconv.FormatBool(avail))
}

I contend that such error types are often preferrable to sentinel errors, for three reasons:

  • Performance: checking for an error type can be faster than checking for a sentinel error value.
  • Non-reassignability: contrary to an exported variable, a type cannot be clobbered.
  • Extensibility: such types can be enriched with additional fields carrying contextual information about the failure in a backward-compatible manner.

In the remainder of this post, I shall substantiate each of these claims in more detail.

Performance

errors.Is and errors.As are slow

The release of Go 1.13 marked the inception of functions errors.Is and errors.As in the standard library:

package errors

func Is(err, target error) bool { /* ... */ }

func As(err error, target any) bool { /* ... */ }

Since then, errors.Is has gained popularity as a better alternative to a direct comparison (with == or !=) of an error value against a sentinel error; similarly, calls to errors.As have gradually superseded type assertions of an error value against a target type.

Unfortunately, despite their powerful tree-traversing semantics, errors.Is and errors.As have taken their toll on performance. Both functions indeed rely on reflection to perform safety checks and forestall panics; and reflection can be slow, sometimes to the point of becoming a performance bottleneck, especially in CPU-bound workloads (such as parsing X.509 certificates). You may be tempted to outright dismiss my performance concerns about errors.Is and errors.As, perhaps by arguing that, in typical programs, only the happy path needs be performant; but bear in mind that, at least in some programs, the happy path happens to be less exercised than unhappy paths.

Function errors.As actually relies on reflection even more heavily than function errors.Is does; moreover, its signature is such that a typical call to it incurs an allocation. Therefore, errors.As typically is much slower than errors.Is. Here are some benchmarks that pits them against each other, as well as against a more powerful alternative (errutil.Find), which I’ll introduce shortly

package bluesky_test

import (
  "errors"
  "testing"

  "example.com/bluesky"
  "github.com/jub0bs/errutil"
)

var sink bool

func BenchmarkErrorChecking(b *testing.B) {
  b.Run("k=errors.Is", func(b *testing.B) {
    for b.Loop() {
      sink = errors.Is(bluesky.ErrInvalidUsername, bluesky.ErrInvalidUsername)
    }
  })
  b.Run("k=errors.As", func(b *testing.B) {
    var err error = new(bluesky.UnknownAvailabilityError)
    for b.Loop() {
      var target *bluesky.UnknownAvailabilityError
      sink = errors.As(err, &target)
    }
  })
  b.Run("k=errutil.Find", func(b *testing.B) {
    var err error = new(bluesky.UnknownAvailabilityError)
    for b.Loop() {
      _, sink = errutil.Find[*bluesky.UnknownAvailabilityError](err)
    }
  })
}

And here are some benchmark results comparing errors.Is and errors.As:

$ benchstat -col '/k@(errors.Is errors.As)' bench.out
goos: darwin
goarch: amd64
pkg: example.com/bluesky
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
                │  errors.Is  │               errors.As               │
                │   sec/op    │    sec/op     vs base                 │
ErrorChecking-8   8.657n ± 6%   92.160n ± 9%  +964.51% (p=0.000 n=20)

                │ errors.Is  │          errors.As           │
                │    B/op    │    B/op     vs base          │
ErrorChecking-8   0.000 ± 0%   8.000 ± 0%  ? (p=0.000 n=20)

                │ errors.Is  │          errors.As           │
                │ allocs/op  │ allocs/op   vs base          │
ErrorChecking-8   0.000 ± 0%   1.000 ± 0%  ? (p=0.000 n=20)

At first sight, the scale tips more towards sentinel errors than towards concrete error types, since errors.Is is more than 10 times as fast as errors.As is, at least on my trusty 2016 Macbook Pro. But wait; there’s more.

Generics to the rescue

Fortunately, thanks to generics, a better alternative to errors.As is possible, and one that adheres remarkably closely to errors.As’s semantics. I recently released such an alternative as part of my github.com/jub0bs/errutil library:

package errutil

// Find finds the first error in err's tree that matches type T,
// and if so, returns the corresponding value and true.
// Otherwise, it returns the zero value and false.
//
// rest of the documentation omitted
//
func Find[T error](err error) (T, bool)

In general, calls to errors.As can advantageously be refactored to calls to errutil.Find, as shown in the diff below:

 package main

 import (
-  "errors"
   "fmt"
   "os"
   "strconv"

   "example.com/bluesky"
+  "github.com/jub0bs/errutil"
 )

 func main() {
   if len(os.Args) < 2 {
     fmt.Fprintf(os.Stderr, "usage: %s <username>\n", os.Args[0])
     os.Exit(1)
   }
   username := os.Args[1]
   avail, err := bluesky.IsAvailable(username)
-  var iuerr *bluesky.InvalidUsernameError
-  if errors.As(err, &iuerr) {
+  if _, ok := errutil.Find[*bluesky.InvalidUsernameError](err); ok {
     const tmpl = "%q is not valid on Bluesky.\n",
     fmt.Fprintf(os.Stderr, tmpl, username)
     os.Exit(1)
   }
-  var uaerr *bluesky.UnknownAvailabilityError
-  if errors.As(err, &uaerr) {
+  if _, ok := errutil.Find[*bluesky.UnknownAvailabilityError](err); ok {
     const tmpl = "The availability of %q on Bluesky could not be checked.\n",
     fmt.Fprintf(os.Stderr, tmpl, username)
     os.Exit(1)
   }
   if err != nil {
     fmt.Fprintln(os.Stderr, err)
     os.Exit(1)
   }
   fmt.Println(strconv.FormatBool(avail))
 }

In my opinion, errutil.Find is superior to errors.As on at least three counts:

  • It’s more ergonomic: it doesn’t require callers to pre-declare a variable of the target dynamic type.
  • It’s more type-safe: thanks to its generic type constraint, it’s guaranteed not to panic.
  • It’s more efficient: because it eschews reflection, it is faster and incurs fewer allocations, as benchmark results attest.

Incidentally, the error-inspection draft design proposal suggests that errors.As would have been very similar to my errutil.Find function if the Go team had managed to crack the parametric-polymorphism nut (Go 1.18) in time for errors.As’s inception in the standard library (Go 1.13).

If you’re tempted to adopt errutil.Find in your projects but are reluctant to add a dependency just for one tiny function, feel free to simply copy errutil.Find’s source code where needed; after all, as Rob Pike puts it:

A little copying is better than a little dependency.


Crucially, my benchmarks also show that opting for concrete error types and checking them with errutil.Find is about twice as fast as sticking with sentinel errors and checking them with errors.Is:

$ benchstat -col '/k@(errors.Is errutil.Find)' bench.out
goos: darwin
goarch: amd64
pkg: example.com/bluesky
cpu: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
                │  errors.Is  │            errutil.Find             │
                │   sec/op    │   sec/op     vs base                │
ErrorChecking-8   8.657n ± 6%   3.822n ± 9%  -55.85% (p=0.000 n=20)

                │ errors.Is  │          errutil.Find          │
                │    B/op    │    B/op     vs base            │
ErrorChecking-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=20) ¹
¹ all samples are equal

                │ errors.Is  │          errutil.Find          │
                │ allocs/op  │ allocs/op   vs base            │
ErrorChecking-8   0.000 ± 0%   0.000 ± 0%  ~ (p=1.000 n=20) ¹

Alright, but I’m conscious that a slight performance boost on unhappy paths may not be compelling enough for you to favour concrete error types over sentinel values. What else is there?

Non-reassignability

Despite continuous improvement from one minor release to the next, the Go programming language still has warts. One particularly unfortunate affordance is that any package can clobber the variables exported by a package it imports:

package main

import (
  "fmt"
  "os"
)

func main() {
  os.Stdout = nil // 🙄
  fmt.Println("Hello, 世界") // prints nothing
}

And because cross-package reassignment of exported variables is possible, at least some people are likely to depend on it. Take heed of Hyrum Wright’s eternal words, which extend to language design:

With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.

Although cross-package reassignment of exported variables can prove convenient for testing, it is fraught with peril:

  • it’s a vector for mutable global state, which is best avoided;
  • it cannot (in general) be performed in a concurrency-safe manner.

Sentinel errors, being exported variables, are not immune to cross-package reassignment, which can lead to frightful results. For example, assigning nil to “pseudo-error” io.EOF would break most implementations of io.Reader:

package p

import "io"

func init() {
  io.EOF = nil // 😱
}

You could argue that nobody in their right mind would ever do this, or that code analysers could be written to detect cross-package clobbering, and you’d be mostly right. Deplorably, at the time of writing, neither staticcheck nor capslock implement checks for such abuse. Besides, forbidding such abuse at compile time would be more satisfying.

Dave Cheney’s “constant errors” approach

Before I explain why concrete error types fit that bill too, I have to mention prior art. In his dotGo 2019 talk and its companion blog post (which both predate Go 1.13), Dave Cheney revisits one of his ideas: an ingenious (though ultimately flawed) alternative technique for declaring sentinel errors in a such a way that they cannot be clobbered.

In Go, constants are values known at compile time and are limited to numbers, booleans, and strings. Interface type error doesn’t fit in any of those categories; no value of type error can be declared as a constant:

package bluesky

import "errors"

const ErrInvalidUsername = errors.New("invalid username") // ❌ compilation error

Dave’s approach consists in declaring a (non-exported) type based on string (therefore compatible with constants), equip that type with an Error method (using a value receiver) so as to make it satisfy the error interface, and use that type as a vessel for declaring sentinel errors within the package of interest:

package bluesky

type bsError string

func (e bsError) Error() string {
  return string(e)
}

const ErrInvalidUsername bsError = "invalid username"

Clobbering symbol ErrInvalidUsername is then impossible:

package p

import "example.com/bluesky"

func init() {
  bluesky.ErrInvalidUsername = "" // ❌ compilation error
}

Although this approach achieves its stated goal, it is no panacea:

  1. Symbol ErrInvalidUsernam now is of some non-exported type; I think most Gophers would agree that choosing a non-exported type for an exported member of one’s package is unidiomatic. The standard library itself only contains a handful of such declarations, and I do wonder whether its maintainers regret, in retrospect, ever introducing them…
  2. Because there cannot be constants of type *string, type bsError must use a value receiver in its Error method; therefore, comparing two bsError values involves a byte-by-byte comparison of their underlying string values, and such string comparison is more expensive than a simple pointer comparison.

Incidentally, the design of syscall.Errno is remarkably similar in spirit to Dave’s constant-error type but suffers from neither of the two shortcomings listed above.

Dave’s approach has been and remains popular among some Gophers; for instance, Dylan Bourque, a respected member of the Go community and frequent host of the new Go-themed Fallthrough podcast, still counts himself as a fan of it. As far as I’m concerned, though, the unidiomatic and inefficient nature of Dave’s approach is reason enough to discourage its use.


Allow me a short digression. Although I generally enjoy and agree with Dave’s output, I’m at odds with another idea that he puts forward in his dotGo talk. As he extols Go’s constants system, Dave fixates on one property of untyped constants, one he refers to as “fungibility”. By a perplexing leap of logic, Dave concludes that sentinel errors too ought to be fungible, and laments that the identity of error-factory function errors.New’s results cannot be reduced to their error messages:

package main

import (
  "errors"
  "fmt"
)

func main() {
  err1 := errors.New("oh no")
  err2 := errors.New("oh no")
  fmt.Println(err1 == err2) // false (to Dave's chagrin)
}

However, as Axel Wagner (a.k.a. Merovius) astutely points out on Reddit, the behaviour that Dave wishes for would have undesirable effects, so much so that errors.New’s test suite includes an inequality check.


Preventing clobbering is a laudable goal, but it can be achieved in other ways. Leave constants aside for a moment… Can you think of something else that cannot be “clobbered”? That’s right: types themselves!

Types cannot be “clobbered”

Type declarations like InvalidUsernameError’s and UnknownAvailabilityError’s simply cannot be modified in any way by clients of your package:

package p

import "example.com/bluesky"

func init() {
    bluesky.InvalidUsernameError = struct{}     // ❌ compilation error
    bluesky.UnknownAvailabilityError = struct{} // ❌ compilation error
}

Easy-peasy! But such concrete error types have one more ace up their sleeve…

Extensibility

A sentinel error cannot carry information about the failure beyond its identity. For example, io.EOF indicates that a source of bytes has been exhausted, but it doesn’t say which source of bytes; and this omission is tolerable in good ol’ io.EOF’s case.

But some failure cases beg, at one stage or another, for contextual information. In a later version of your bluesky package, you may well want to allow callers of bluesky.IsAvailable to programmatically interrogate that function’s error result in more detail. Legitimate questions include the following:

  • What username was being queried when this failure occurred?
  • What is the underlying cause of the failure? An expected status code? A failed request? Something else?

The error types that I’ve been advocating since the beginning of this post can easily accommodate additional fields carrying contextual information about the failure:

 package bluesky

 import (
+  "errors"
+  "fmt"
   "math/rand/v2"
 )

-type InvalidUsernameError struct{}
+type InvalidUsernameError struct {
+  Username string
+}

-func (*InvalidUsernameError) Error() string {
-  return "invalid username"
+func (e *InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
 }

-var errInvalidUsername = new(InvalidUsernameError)
-
-type UnknownAvailabilityError struct{}
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}

-func (*UnknownAvailabilityError) Error() string {
-  return "unknown availability"
+func (e *UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
 }

-var errUnknownAvailability = new(UnknownAvailabilityError)
-
+func (e *UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}

 func IsAvailable(username string) (bool, error) {
   // actual implementation omitted
   switch rand.IntN(3) {
   case 0:
-  return false, errInvalidUsername
+  return false, &InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, errUnknownAvailability
+    return false, &UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

Those changes would allow clients to extract such contextual information. Moreover, those changes would not break any client! Existing calls to errors.As or to the faster errutil.Find that target bluesky’s concrete error types would continue to work as before.

On the importance of using a pointer receiver

In the multiverse of design choices, let’s examine a world in which you instead used a value receiver for the Error method of your error types:

package bluesky

import "math/rand/v2"

type InvalidUsernameError struct{}

func (InvalidUsernameError) Error() string {
  return "invalid username"
}

type UnknownAvailabilityError struct{}

func (UnknownAvailabilityError) Error() string {
  return "unknown availability"
}

func IsAvailable(username string) (bool, error) {
  // actual implementation omitted
  switch rand.IntN(3) {
  case 0:
    return false, InvalidUsernameError{}
  case 1:
    return false, UnknownAvailabilityError{}
  default:
    return false, nil
  }
}

Note that your clients would then be free to rely on errors.Is (or even a direct comparison) rather than on errors.As or errutil.Find:

avail, err := IsAvailable("🤪")
if errors.Is(err, bluesky.InvalidUsernameError{}) { // true
    // ...
}

Assume that you then augment your concrete error types with additional fields:

 package bluesky

 import (
+  "errors"
+  "fmt"
   "math/rand/v2"
 )

-type InvalidUsernameError struct{}
+type InvalidUsernameError struct {
+  Username string
+}

-func (InvalidUsernameError) Error() string {
-  return "invalid username"
+func (e InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
}

-type UnknownAvailabilityError struct{}
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}

-func (UnknownAvailabilityError) Error() string {
-  return "unknown availability"
+func (e UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
}

+func (e UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}

 func IsAvailable(username string) (bool, error) {
   // actual implementation omitted
   switch rand.IntN(3) {
   case 0:
-  return false, InvalidUsernameError{}
+  return false, InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, UnknownAvailabilityError{}
+    return false, UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

Unfortunately, such changes would break clients who rely on errors.Is:

avail, err := IsAvailable("🤪")
if errors.Is(err, bluesky.InvalidUsernameError{}) { // false
  // ...
}

This example should be enough to convince you to use a pointer receiver for the Error method of your concrete error types.


Edit (2025-04-02): After getting some feedback from Axel Wagner on this section of the post, I feel compelled to mention another (perhaps even more crucial) strength of pointer receivers: contrary to value receivers, pointer receivers lift the ambiguity as to which target type should be used in type assertions and calls to errors.As or errutil.Find.


Transitioning away from sentinel errors is precarious

If you started with sentinel errors, be ready to carry that burden for a long time; until the next major-version release of your bluesky package, at the very least. Admittedly, if you’re lucky and all of your clients happen to rely on errors.Is rather than on a direct comparison (with == or !=), you could leverage Is methods (whose existence errors.Is checks for) to safely transition to concrete error types:

 package bluesky

 import (
   "errors"
+  "fmt"
   "math/rand/v2"
 )

+// Deprecated: use InvalidUsernameError instead.
 var ErrInvalidUsername = errors.New("invalid username")

+// Deprecated: use UnknownAvailabilityError instead.
 var ErrUnknownAvailability = errors.New("unknown availability")

+type InvalidUsernameError struct {
+  Username string
+}
+
+func (e *InvalidUsernameError) Error() string {
+  return fmt.Sprintf("invalid username %q", e.Username)
+}
+
+func (*InvalidUsernameError) Is(err error) bool {
+  return err == ErrInvalidUsername
+}
+
+type UnknownAvailabilityError struct {
+  Username string
+  Cause    error
+}
+
+func (e *UnknownAvailabilityError) Error() string {
+  return fmt.Sprintf("unknown availability of %q", e.Username)
+}
+
+func (*UnknownAvailabilityError) Is(err error) bool {
+  return err == ErrUnknownAvailability
+}
+
+func (e *UnknownAvailabilityError) Unwrap() error {
+  return e.Cause
+}
+
 func IsAvailable(username string) (bool, error) {
   // actual implementation omitted
   switch rand.IntN(3) {
   case 0:
-  return false, ErrInvalidUsername
+  return false, &InvalidUsernameError{
+    Username: username,
+  }
   case 1:
-    return false, ErrUnknownAvailability
+    return false, &UnknownAvailabilityError{
+      Username: username,
+      Cause:    errors.New("oh no"),
+    }
   default:
     return false, nil
   }
 }

If you found yourself in this ideal situation, those changes would break none of your clients; that much is true. In general, though, I would refrain from such unbridled optimism. Therefore, if you anticipate that you’ll need error types at some stage, I think that you’re better off starting with them and skipping sentinel errors altogether.

Discussion

Concrete error types have served me well, especially in github.com/jub0bs/cors, to which I recently added support for programmatic handling of CORS-configuration errors. This post might have convinced you to favour them over sentinel errors from now on.

However, I don’t want to oversell concrete error types either. They may not be a good fit for some of your projects, for reasons beyond my knowledge. If you’re in that situation, I’d like to hear from you; find me on social media or Gophers Slack.

You may for instance prefer opaque errors and letting your clients assert errors on behaviour rather than on type, an approach promulgated by Dave Cheney that I didn’t dare cover here for fear of turning this already lengthy post into a soporific essay.

Whatever you do, keep in mind that patterns are contextual, not absolute. Always exercise judgement. Be deliberate in your design choices, and resist the temptation to delegate them to some mindless AI tool. 😇

Acknowledgments

Some of the public and private conversations that I’ve had with other Gophers on Slack fed into this post. Thanks in particular to Roger Peppe, Axel Wagner, Justen Walker, Bill Moran, Noah Stride, and Frédéric Marand.