18 minutes
Why concrete error types are superior to sentinel errors
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 functionerrors.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:
- 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… - Because there cannot be constants of type
*string
, typebsError
must use a value receiver in itsError
method; therefore, comparing twobsError
values involves a byte-by-byte comparison of their underlyingstring
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.