Go errors are plain values you return and check. Learn to wrap them with context, find the cause with errors.Is and errors.As, join several at once, and keep panic and recover for real bugs.
Go has no exceptions for ordinary failures. A function that can fail returns an error as its last result, and the caller decides what to do with it. That makes error handling visible on every line, and it means the tools for adding context and finding the cause matter a lot.
This post covers those tools, from errors.New through wrapping, errors.Is, errors.As and errors.Join, and then panic and recover, which are for a different job. Every program below was run on Go 1.26, and its output is pasted from the run.
An error is any value with an Error method
The error type in Go is a built-in interface with a single method, Error() string. Any type that has that method is an error. The standard library gives you two quick ways to make one: errors.New for a fixed message, and fmt.Errorf when the message needs values in it.
package main
import (
"errors"
"fmt"
"strconv"
)
func parseAge(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("age %q is not a number", s)
}
if n < 0 {
return 0, errors.New("age can't be negative")
}
return n, nil
}
func main() {
for _, in := range []string{"42", "-3", "old"} {
age, err := parseAge(in)
if err != nil {
fmt.Println("error:", err)
continue
}
fmt.Println("age:", age)
}
var err error = errors.New("disk full")
fmt.Printf("%T\n", err)
}
It prints:
age: 42
error: age can't be negative
error: age "old" is not a number
*errors.errorString
parseAge returns a value and an error. On success the error is nil. On failure the value is 0 and the error says what went wrong. The caller checks if err != nil straight after the call and deals with it before touching age.
The last line shows what errors.New really makes: a pointer to a small unexported struct that holds the message. You never need that type by name. You only need the Error method, and fmt.Println calls it for you.
Why Go makes you check
The shape if err != nil { return err } shows up everywhere in Go, and it’s deliberate. An exception can jump out of any call, so you can’t tell by reading a function which lines might leave it early. In Go, every line that can fail has its check right under it. The failure path is ordinary code that you can read, step through and test.
The cost is typing. The benefit is that nothing leaves a function without you seeing where. And as the part on functions showed, you can’t drop an error by accident: to ignore one you have to write _, where a reviewer can see it.
Sentinel errors and errors.Is
A sentinel error is a package-level error value that callers compare against. By convention its name starts with Err. You make it once with errors.New and return that same value every time the condition happens.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var users = map[int]string{1: "ada", 2: "linus"}
func findUser(id int) (string, error) {
name, ok := users[id]
if !ok {
return "", ErrNotFound
}
return name, nil
}
func main() {
_, err := findUser(7)
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
same := errors.New("not found")
fmt.Println(errors.Is(err, same))
}
It prints:
not found
true
true
false
err == ErrNotFound is true, because findUser returned that exact value. errors.Is agrees. The last line is the one to notice: a second error with the same text is not the same error. errors.New makes a new pointer each time, and errors compare by identity, not by message. So never check an error by comparing its string.
The standard library uses sentinels widely. io.EOF means “no more input”, and sql.ErrNoRows means a query found nothing. Right now == and errors.Is give the same answer. They stop agreeing as soon as someone adds context, which is the next section.
Wrapping: adding context with %w
A bare “not found” coming out of a big program tells you almost nothing. Not found where? While doing what? Go’s answer is to wrap the error: each function that passes it up adds a short note about what it was doing. fmt.Errorf wraps when you use the %w verb.
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func openFile(name string) error {
return ErrNotFound
}
func readConfig(name string) error {
if err := openFile(name); err != nil {
return fmt.Errorf("open %s: %w", name, err)
}
return nil
}
func startServer() error {
if err := readConfig("config.json"); err != nil {
return fmt.Errorf("read config: %w", err)
}
return nil
}
func main() {
err := startServer()
if err != nil {
err = fmt.Errorf("start server: %w", err)
}
fmt.Println(err)
fmt.Println(err == ErrNotFound)
fmt.Println(errors.Is(err, ErrNotFound))
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" %q\n", e.Error())
}
}
It prints:
start server: read config: open config.json: not found
false
true
"start server: read config: open config.json: not found"
"read config: open config.json: not found"
"open config.json: not found"
"not found"
The error went up three levels, and each level put its own words in front. The final message reads left to right from the outermost task down to the root cause. That’s the Go style: short lowercase phrases, joined by colons, no “error:” or “failed to” on every level.
Now err == ErrNotFound is false, because err is a wrapper, not the sentinel. errors.Is is still true, because it looks inside. The loop at the end shows what it looks through: errors.Unwrap peels off one layer at a time until it reaches the original.
Explain it like I’m ten
Imagine a note passed up a line of people. The first kid writes “not found” and hands it to the next person.
That person doesn’t throw the note away or rewrite it. They put it inside an envelope and write on the outside: “while opening config.json:”. The next person puts that envelope inside a bigger one and writes “while reading the config:”. By the time it reaches the teacher, the outside says the whole story, and the original note is still in the middle.
errors.Is is the teacher opening envelope after envelope, looking for the one note that says “not found”. It doesn’t matter how many envelopes there are.
The precise version
fmt.Errorf with %w returns an error that stores both the new message and the error you passed in. That wrapper has an Unwrap() error method that returns the inner error. The chain is a linked list: each wrapper points at the one inside it, and the innermost error has no Unwrap.
errors.Is(err, target) walks that list. At each step it checks err == target, and also calls an Is(error) bool method if the error has one. It returns true at the first match and false when the chain runs out. errors.Unwrap does one step of the walk by hand.
Where the analogy breaks: real envelopes hide the note inside, but a Go wrapper doesn’t. Its message already contains the inner message, which is why printing the outer error shows the whole chain. And wrapping isn’t automatic. If a function uses %v instead of %w, it copies the text but drops the envelope.
%w or %v
The two verbs print the same message, and only one of them keeps the chain:
package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
func main() {
wrapped := fmt.Errorf("load user 7: %w", ErrNotFound)
flattened := fmt.Errorf("load user 7: %v", ErrNotFound)
fmt.Println(wrapped)
fmt.Println(flattened)
fmt.Println(errors.Is(wrapped, ErrNotFound))
fmt.Println(errors.Is(flattened, ErrNotFound))
}
It prints:
load user 7: not found
load user 7: not found
true
false
You can’t tell them apart by reading the output, and that’s what makes the difference easy to miss. Use %w when callers may need to check the cause. Use %v when you deliberately want to hide it, for example so that a detail of your storage layer doesn’t become part of your package’s API.
Handle an error once
Each error should be handled once: either you return it, with context added, or you log it and stop there. Doing both is a common habit, and it fills logs with the same failure told several times.
package main
import (
"errors"
"log"
"os"
)
func readConfig() error {
return errors.New("config.json: file not found")
}
func startServer() error {
err := readConfig()
if err != nil {
log.Println("could not read config:", err)
return err
}
return nil
}
func main() {
log.SetFlags(0)
log.SetOutput(os.Stdout)
if err := startServer(); err != nil {
log.Println("server failed:", err)
}
}
It prints:
could not read config: config.json: file not found
server failed: config.json: file not found
(log.SetFlags(0) turns off the timestamp so the output is the same on every run.)
One failure, two log lines. In a real program with five layers, that’s five lines, often far apart in the log, and someone reading at 3am counts five problems. Worse, the second line lost the context the first one had.
The fix is to pick one. startServer should return fmt.Errorf("read config: %w", err) and log nothing. main is the top, so it has nowhere to return to. It logs once, and that one line reads server failed: read config: config.json: file not found, with the whole story in order.
Custom error types and errors.As
A custom error type carries structured data that a caller can act on, not just a message. When a caller needs to know which field failed validation, or which status code an HTTP call returned, a sentinel isn’t enough. You define a struct with the fields and give it an Error method.
package main
import (
"errors"
"fmt"
)
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return e.Field + ": " + e.Reason
}
func validate(email string) error {
if email == "" {
return &ValidationError{Field: "email", Reason: "is required"}
}
return nil
}
func createUser(email string) error {
if err := validate(email); err != nil {
return fmt.Errorf("create user: %w", err)
}
return nil
}
func main() {
err := createUser("")
fmt.Println(err)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("field:", ve.Field)
fmt.Println("reason:", ve.Reason)
}
if ve, ok := errors.AsType[*ValidationError](err); ok {
fmt.Println("AsType found field:", ve.Field)
}
}
It prints:
create user: email: is required
field: email
reason: is required
AsType found field: email
errors.Is asks “is this particular value somewhere in the chain?”. errors.As asks “is there an error of this type somewhere in the chain, and if so, give it to me”. You declare a variable of the type you want, pass its address, and errors.As walks the chain. When it finds a *ValidationError, it stores it in ve and returns true. The wrap from createUser didn’t get in the way.
The pointer to ve trips people up. errors.As needs somewhere to put what it finds, so it takes &ve, which is a **ValidationError. Pass ve itself and go vet stops you.
Go 1.26 adds errors.AsType, a generic version that returns the match and a bool, with no variable to declare first. It does the same walk. You’ll see errors.As in almost all existing code, and AsType in new code as it catches on.
One warning when you write functions like validate. Return a plain nil on success, never a nil *ValidationError stored in an error. That second one isn’t equal to nil, which is the nil-interface trap from the part on interfaces.
errors.Join: several errors at once
Sometimes one call has several independent things wrong, and reporting only the first makes the user fix them one at a time. errors.Join, added in Go 1.20, combines errors into one.
package main
import (
"errors"
"fmt"
)
var ErrTooShort = errors.New("password too short")
func checkSignup(name, password string) error {
var errs []error
if name == "" {
errs = append(errs, errors.New("name is required"))
}
if len(password) < 8 {
errs = append(errs, ErrTooShort)
}
return errors.Join(errs...)
}
func main() {
err := checkSignup("", "abc")
fmt.Println(err)
fmt.Println("---")
fmt.Println(errors.Is(err, ErrTooShort))
fmt.Println(checkSignup("ada", "correct horse"))
}
It prints:
name is required
password too short
---
true
<nil>
The joined error’s message puts each error on its own line. errors.Is and errors.As search every branch, so the check for ErrTooShort still works.
The last line is the handy part. errors.Join ignores nil errors, and when there’s nothing left it returns nil. So you can collect problems in a slice and return errors.Join(errs...) without checking whether the slice is empty.
panic is for bugs, not for errors
A panic stops the normal flow of a goroutine. Deferred calls still run, and then, unless something recovers, the whole program crashes with a message and a stack trace. That’s the right outcome for a bug: something the programmer got wrong, or a state the code believes can’t happen. A missing file, a bad user input or a network timeout isn’t a bug. Those are errors, and they get returned.
package main
import "fmt"
type Direction int
const (
North Direction = iota
South
)
func (d Direction) String() string {
switch d {
case North:
return "north"
case South:
return "south"
}
panic(fmt.Sprintf("unknown Direction %d", int(d)))
}
func main() {
fmt.Println(North.String())
fmt.Println(Direction(9).String())
}
It prints two lines, then stops:
north
panic: unknown Direction 9
After those two lines comes goroutine 1 [running]: and a stack trace pointing at the panic line, and the program exits with status 2. There are only two directions, so Direction(9) means some code somewhere built a value it shouldn’t have. Returning an error here would force every caller of String to handle a case that only a bug can cause. A panic says “fix the code” loudly, right where it went wrong.
The runtime panics for the same kind of reason: indexing past the end of a slice, writing to a nil map, dividing an integer by zero. You’ve already met the first of those in the part on slices.
recover: turning a panic back into an error
recover is a built-in that stops a panic in progress and returns the value that was passed to panic. It only has an effect when it’s called directly from a deferred function while the goroutine is panicking. The normal use is at a boundary: code that runs someone else’s function and doesn’t want their bug to crash everything.
package main
import (
"errors"
"fmt"
)
func safeRun(name string, job func()) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("job %s panicked: %v", name, r)
}
}()
job()
return nil
}
func main() {
err := safeRun("ok", func() {
fmt.Println("running ok")
})
fmt.Println("result:", err)
err = safeRun("bad", func() {
var counts map[string]int
counts["x"]++
})
fmt.Println("result:", err)
err = safeRun("custom", func() {
panic(errors.New("state machine reached an impossible state"))
})
fmt.Println("result:", err)
fmt.Println("main carries on")
}
It prints:
running ok
result: <nil>
result: job bad panicked: assignment to entry in nil map
result: job custom panicked: state machine reached an impossible state
main carries on
The first job ran normally. recover returned nil, so the deferred function changed nothing. The second job wrote to a nil map, which makes the runtime panic. The panic unwound out of job(), the deferred function ran, recover caught it, and the closure put an error into the named result err. That’s the same trick as the deferred closure in the part on functions. The third job panicked on purpose and was caught the same way.
This is exactly what Go’s net/http server does for each request. A handler that panics gets its request dropped and logged, and the server keeps serving everyone else. You’ll see that in the part on HTTP servers.
Don’t use recover to build exceptions out of panic. If a function can fail in a normal way, return an error. Recover only at a boundary where a crash would be worse than a logged failure.
Where recover doesn’t work
A call to recover only stops a panic when a deferred function calls it directly. Called anywhere else, it does nothing and returns nil.
package main
import "fmt"
func helper() {
if r := recover(); r != nil {
fmt.Println("helper recovered:", r)
}
}
func main() {
fmt.Println("recover outside a panic:", recover())
defer func() {
helper()
fmt.Println("the deferred function returns")
}()
panic("boom")
}
It prints three lines, then stops:
recover outside a panic: <nil>
the deferred function returns
panic: boom
The first recover ran when nothing was panicking, so it returned nil. The second one sits inside helper, and helper is called by the deferred function rather than being the deferred function. That one level of indirection is enough: recover returns nil, helper prints nothing, and the panic carries on and crashes the program. Moving the recover call into the deferred closure itself would catch it.
The other limit is goroutines. A deferred recover only catches panics in its own goroutine:
package main
import "fmt"
func main() {
defer func() {
if r := recover(); r != nil {
fmt.Println("main recovered:", r)
}
}()
go func() {
panic("worker failed")
}()
select {} // wait forever; the worker's panic ends the program first
}
It prints one line, then stops:
panic: worker failed
main has a perfectly good recover, and it never runs. The panic happened in another goroutine, which has no deferred recover of its own, and an unrecovered panic in any goroutine crashes the whole program. So code that starts goroutines running untrusted work puts the defer–recover inside each goroutine. The part on goroutines covers starting and waiting for them properly.
Re-panicking
A deferred function sometimes recovers a panic, looks at it, and decides it can’t handle it after all. It can log what it knows and call panic again with the same value.
package main
import "fmt"
func main() {
defer func() {
r := recover()
fmt.Println("logging, then panicking again:", r)
panic(r)
}()
panic("invariant broken: balance below zero")
}
It prints two lines, then stops:
logging, then panicking again: invariant broken: balance below zero
panic: invariant broken: balance below zero [recovered, repanicked]
Look at the end of the second line. When a panic is recovered and the same value is panicked again, the crash message says [recovered, repanicked] and prints the value once. If you see that marker in a crash, some code up the stack caught the panic and let it go.
Which should I use?
The choice comes down to who needs to react, and how.
| Situation | Use | Caller checks with |
|---|---|---|
| Something failed, and the caller only needs to know that it did | Return an error from errors.New or fmt.Errorf, wrapping with %w |
err != nil |
| The caller needs to recognise one specific condition, like “not found” | A sentinel: var ErrNotFound = errors.New(...) |
errors.Is |
| The caller needs details: which field, which code, which retry delay | A custom error type with fields | errors.As or errors.AsType |
| Several independent things failed at once | errors.Join |
errors.Is or errors.As, which search every part |
| A bug or an impossible state, something only a code change fixes | panic |
Nothing. Fix the code. Recover only at a boundary |
What to remember
erroris an interface with one method,Error() string. Return it as the last result and checkif err != nilright after the call.- Handle each error once: return it with context, or log it. Not both.
- Wrap with
fmt.Errorf("doing X: %w", err). The message builds up level by level, and%vwould drop the chain. errors.Isfinds a specific value anywhere in the chain. Compare errors with it, not with==and never by message text.errors.As, orerrors.AsTypefrom Go 1.26, pulls a custom error type out of the chain so you can read its fields.errors.Joincombines several errors and returnsnilwhen they’re allnil.panicis for bugs.recoveronly works when called directly in a deferred function of the panicking goroutine, and it belongs at boundaries.
Every time an error passes up a level, add what you were doing, and keep the original inside.