Errors
Go has no exceptions. Errors are values — the same kind of values you pass around
every day. The result is verbose (if err != nil is one of the most
grepped strings in any Go codebase) but predictable. Every error path is
explicit; nothing escapes upward without your consent.
1 · The intuition
In Java or Python, an error escapes upward via the stack until something catches it. In Go, errors are just values — usually the second (or last) return of a function. The caller decides immediately: handle, ignore (rare), or pass upward by returning. There's no implicit propagation. Every error is documented in the function signature.
error type. A built-in interface:
type error interface { Error() string }. Anything with an
Error() string method satisfies it. Most often you create errors with
errors.New("msg") or fmt.Errorf("msg: %w", err).2 · Try it
package main
import (
"errors"
"fmt"
"strconv"
)
// Returns (result, err). nil error means "all good".
func parsePositive(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("not a number: %w", err)
}
if n <= 0 {
return 0, errors.New("must be positive")
}
return n, nil
}
func main() {
for _, in := range []string{"42", "abc", "-5"} {
n, err := parsePositive(in)
if err != nil {
fmt.Printf("%q: error: %v
", in, err)
continue
}
fmt.Printf("%q: ok, n=%d
", in, n)
}
}3 · Wrapping with %w
fmt.Errorf("context: %w", err) wraps an error, preserving the
original. The wrapped error can later be unwrapped, compared with
errors.Is, or matched by type with errors.As.
package main
import (
"errors"
"fmt"
"os"
)
func readConfig() error {
_, err := os.Open("/etc/myapp.conf")
if err != nil {
return fmt.Errorf("readConfig: %w", err) // wrap, don't replace
}
return nil
}
func main() {
err := readConfig()
if err != nil {
fmt.Println("got:", err)
// Check WHAT KIND of error — unwraps the chain
if errors.Is(err, os.ErrNotExist) {
fmt.Println("config file missing — using defaults")
}
}
}4 · Custom error types — errors.As
package main
import (
"errors"
"fmt"
)
// A typed error — carries data
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return "validation: " + e.Field + ": " + e.Msg
}
func validate(email string) error {
if email == "" {
return &ValidationError{Field: "email", Msg: "is required"}
}
return nil
}
func main() {
err := validate("")
if err != nil {
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Printf("field %q failed: %s
", verr.Field, verr.Msg)
}
}
}Is checks whether the error
chain contains a specific sentinel value. As checks whether
the chain contains an error of a specific type, and binds it. Use
Is for sentinel comparisons (e.g. io.EOF); use
As when you need to inspect the carried data.5 · panic and recover — the escape hatch
panic aborts the current goroutine, running deferred functions on
the way out. recover stops a panic, but only inside a deferred
function. Reserved for: programming bugs (nil pointer dereference, out-of-bounds
index), unrecoverable corruption, and at API boundaries you want to convert to
errors.
package main
import "fmt"
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // panics if b == 0
}
func main() {
r, err := safeDiv(10, 2)
fmt.Printf("ok: r=%d err=%v
", r, err)
r, err = safeDiv(10, 0)
fmt.Printf("zero: r=%d err=%v
", r, err)
}This pattern is the standard library's go-to at HTTP-handler boundaries:
panics inside one handler shouldn't crash the whole server. net/http
does exactly this for you.
6 · Sentinel errors — the standard library pattern
Many stdlib packages expose sentinel errors — package-level variables
you compare against with errors.Is. The canonical example is
io.EOF.
package main
import (
"errors"
"fmt"
"io"
"strings"
)
func main() {
r := strings.NewReader("hi")
buf := make([]byte, 8)
for {
n, err := r.Read(buf)
if n > 0 {
fmt.Printf("read %d bytes: %q
", n, buf[:n])
}
if errors.Is(err, io.EOF) {
fmt.Println("EOF reached — clean exit")
break
}
if err != nil {
fmt.Println("unexpected:", err)
break
}
}
}7 · From the wild
var (
ErrStopped = errors.New("server stopped")
ErrCanceled = errors.New("server canceled")
ErrTimeout = errors.New("server timeout")
ErrNoLeader = errors.New("no leader")
)
func (s *EtcdServer) processInternalRaftRequest(ctx context.Context, r pb.InternalRaftRequest) error {
if err := s.raftNode.Propose(ctx, data); err != nil {
return fmt.Errorf("etcdserver: failed to propose: %w", err)
}
select {
case <-ctx.Done():
return s.parseProposeCtxErr(ctx.Err(), startTime)
case <-s.stopping:
return ErrStopped
}
}
// Callers use errors.Is(err, etcdserver.ErrStopped) to react.8 · Coming from another language?
| If you know… | The shift |
|---|---|
| Python / Java | No try/catch. Every fallible call returns (value, err). The "stack unwind" you're used to is replaced by explicit returns. |
| JavaScript | No throw. panic exists but is reserved for programmer bugs. err is the second return, not a special channel. |
| Rust | ≈ Result<T, E> minus the generic. ? doesn't exist; you write if err != nil { return err }. Verbose but explicit. |
| C | Like errno + return code, but the error is a real value with a Stringer interface, and the chain can be deep. |
9 · Common mistakes
- Swallowing errors with
_.v, _ := foo()is occasionally right but more often a bug. If you've truly considered and chosen to ignore, leave a comment. - Using
%vinstead of%w.%vstringifies.%wwraps and preserves the chain forerrors.Is/errors.As. Use%wwhen wrapping;%vwhen just printing. - Panicking in libraries. Library code should return errors. Reserve panic for "impossible" runtime states or programmer bugs (out-of-bounds in your own data structure).
- Recovering everywhere. A blanket
recoverhides real bugs. Recover at well-defined boundaries: HTTP handlers, goroutine entry points, never in tight inner loops. - Comparing errors with
==on wrapped chains.err == io.EOFfails iferris wrapped. Alwayserrors.Is(err, io.EOF). - Returning concrete error types. Return
error(the interface), not*MyError. Otherwisereturn nilmay produce a typed-nil that's!= nilat the call site.
10 · Exercises (~15 min)
- The typed-nil trap. Write a function returning
errorviavar e *MyErr; return e. Thenif err != nil { ... }at the call site. What happens? Why? - Wrap and unwrap. Open a non-existent file. Wrap the error with
fmt.Errorf("step1: %w", err)twice. Print the result. Then useerrors.Is(err, os.ErrNotExist)— verify it still works through the wrap layers. - Custom error. Define
type NotFoundError struct { Key string }withError() string. Return one from a function. Catch it at the caller witherrors.As. - Safe div. Reproduce the panic/recover pattern. Then move
recoveroutside the deferred function — what error message do you get?
11 · When it clicks
- You write
if err != nil { return fmt.Errorf("context: %w", err) }by reflex. - You distinguish
errors.Is(sentinel) fromerrors.As(type) without thinking. - You never use
panicin library code unless the API explicitly documents it. - You wrap at boundaries (where you have meaningful context) and stop wrapping where context would be noise.