Error handling
Go does not have exceptions. Errors are values — instances of the built-in error interface — returned from functions as the last return value. The conventional pattern is if err != nil { return err } propagation; the errors and fmt packages admit error wrapping, type-based discrimination via errors.Is and errors.As, and structured error chains. For unrecoverable conditions, panic unwinds the goroutine; recover (used inside a defer) admits intercepting the panic. The combination — errors as values, explicit propagation, wrapping with %w, panic/recover for genuinely exceptional cases — is the substance of Go’s error story.
The error interface
The built-in error is an interface:
type error interface {
Error() string
}
Any type with an Error() string method satisfies error:
type MyError struct {
Code int
Msg string
}
func (e *MyError) Error() string {
return fmt.Sprintf("error %d: %s", e.Code, e.Msg)
}
var err error = &MyError{Code: 42, Msg: "not found"}
fmt.Println(err) // "error 42: not found"
The nil value of any error type indicates “no error”:
var err error // err == nil
if err == nil {
fmt.Println("no error")
}
The conventional pattern
Functions that may fail return an error as the last return value:
func parseInt(s string) (int, error) {
n, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("parse %q: %w", s, err)
}
return n, nil
}
n, err := parseInt("42")
if err != nil {
return err
}
fmt.Println(n)
The conventional Go discipline:
- Check the error immediately — the next line after the function call.
- Return early on error, propagating up the call stack.
- Wrap errors with context as they propagate.
- Use
nilfor the success case.
The pattern is one of Go’s most distinctive idioms; it produces explicit, traceable error handling at the cost of substantial verbosity.
Creating errors
The errors.New and fmt.Errorf functions are the conventional constructors:
import "errors"
err := errors.New("something went wrong")
err := fmt.Errorf("invalid input: %s", input)
err := fmt.Errorf("operation failed: %w", inner) // %w wraps
The %w verb (since Go 1.13) wraps the inner error, admitting later inspection with errors.Is and errors.As.
Sentinel errors
A sentinel error is a package-level variable representing a specific error condition:
package mypkg
var (
ErrNotFound = errors.New("not found")
ErrPermission = errors.New("permission denied")
ErrInvalidInput = errors.New("invalid input")
)
func Lookup(key string) (Value, error) {
/* ... */
return Value{}, ErrNotFound
}
Callers compare with errors.Is:
v, err := mypkg.Lookup("key")
if errors.Is(err, mypkg.ErrNotFound) {
return defaultValue
}
if err != nil {
return err
}
The conventional naming is ErrXxx for exported sentinels.
Error types
For richer error information, define a struct type:
type ParseError struct {
Line int
Column int
Input string
Reason string
}
func (e *ParseError) Error() string {
return fmt.Sprintf("parse error at %d:%d: %s (input: %q)",
e.Line, e.Column, e.Reason, e.Input)
}
Callers extract the error via errors.As:
err := parse(input)
var pe *ParseError
if errors.As(err, &pe) {
fmt.Printf("error at line %d\n", pe.Line)
}
The pattern admits structured error data while preserving error-chain traversal.
Error wrapping with %w
Wrapping admits adding context without losing the original error:
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return fmt.Errorf("parse config: %w", err)
}
return nil
}
err := loadConfig("/etc/myapp.conf")
// err.Error() = "read config /etc/myapp.conf: open /etc/myapp.conf: no such file or directory"
The wrapped chain admits inspection:
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file does not exist")
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("path: %s\n", pathErr.Path)
}
errors.Is and errors.As
The two principal inspection functions:
errors.Is
Checks whether the error chain contains a specific value (sentinel):
if errors.Is(err, ErrNotFound) {
/* ... */
}
if errors.Is(err, io.EOF) {
/* end of stream */
}
The function walks the chain (via Unwrap()) until it finds a match or reaches the bottom. The mechanism replaces the pre-1.13 pattern of if err == ErrNotFound, which would fail when the error was wrapped.
errors.As
Extracts the first error in the chain matching a type:
var netErr *net.OpError
if errors.As(err, &netErr) {
fmt.Println("network error:", netErr.Op)
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println("path:", pathErr.Path)
}
The target must be a non-nil pointer to a type implementing error. On match, the target is set to the matching error.
Custom unwrapping
Custom error types may implement Unwrap() error to participate in the chain:
type MyError struct {
Inner error
Msg string
}
func (e *MyError) Error() string { return e.Msg }
func (e *MyError) Unwrap() error { return e.Inner }
The errors.Is and errors.As functions walk the chain via Unwrap. For multi-error wrapping (since Go 1.20), Unwrap() []error admits a slice of wrapped errors:
type MultiError struct {
Errs []error
}
func (m *MultiError) Error() string { /* ... */ }
func (m *MultiError) Unwrap() []error { return m.Errs }
The errors.Is and errors.As traverse all branches.
errors.Join
Since Go 1.20, errors.Join combines multiple errors:
errs := []error{
parse(line1),
parse(line2),
parse(line3),
}
combined := errors.Join(errs...)
if combined != nil {
return combined
}
The combined error’s Error() returns each error joined by newlines; errors.Is and errors.As traverse all the joined errors.
panic and recover
For genuinely unrecoverable conditions, panic unwinds the goroutine:
func mustParse(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(fmt.Sprintf("invalid: %s", s))
}
return n
}
The panic propagates up the call stack, running deferred functions along the way; if it reaches the goroutine’s top, the program terminates with a stack trace.
recover (used inside a deferred function) admits intercepting a panic:
func safeCall() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
riskyOperation()
return nil
}
The conventional uses of panic:
- Genuinely unrecoverable conditions — invariant violations, programmer bugs.
MustXxxfunctions —regexp.MustCompile,template.Must.- Initialisation failures —
init()functions panic on critical setup failures.
The conventional uses of recover:
- HTTP handlers — recover panics so one failed request does not crash the server.
- Worker goroutines — recover panics in spawned goroutines to keep the program alive.
- FFI boundaries — recover before crossing into C code.
The discipline: panic for bugs, errors for failures. The conventional Go style returns errors for any condition the caller could plausibly handle.
Common patterns
Simple propagation
func process() error {
if err := step1(); err != nil {
return err
}
if err := step2(); err != nil {
return err
}
if err := step3(); err != nil {
return err
}
return nil
}
Wrapping at boundaries
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadConfig: %w", err)
}
var c Config
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("loadConfig: parse: %w", err)
}
return &c, nil
}
The convention is to wrap at function boundaries with the function name, producing chains like:
loadConfig: parse: invalid character '}' looking for beginning of value
Sentinel-based dispatch
result, err := lookup(key)
switch {
case err == nil:
return result, nil
case errors.Is(err, ErrNotFound):
return defaultValue, nil
case errors.Is(err, ErrPermission):
return zero, fmt.Errorf("access denied: %w", err)
default:
return zero, fmt.Errorf("unexpected: %w", err)
}
Type-based dispatch
err := operation()
var netErr *net.OpError
if errors.As(err, &netErr) {
return retryAfter(netErr.Timeout)
}
var validationErr *ValidationError
if errors.As(err, &validationErr) {
return reportFieldError(validationErr.Field)
}
if err != nil {
return fmt.Errorf("unknown: %w", err)
}
MustXxx for setup
var matchURL = regexp.MustCompile(`^https?://[^\s]+$`)
var template = template.Must(template.ParseFiles("layout.tmpl"))
The pattern is conventional for setup that should not fail; failures here are programmer errors.
Recovery in HTTP handlers
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", 500)
}
}()
next.ServeHTTP(w, r)
})
}
defer for cleanup
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close() // always closes
return process(f)
}
The defer runs even if the function returns early via return. It does not run for os.Exit or external termination.
Multiple errors
func validateAll(items []Item) error {
var errs []error
for i, item := range items {
if err := validate(item); err != nil {
errs = append(errs, fmt.Errorf("item %d: %w", i, err))
}
}
return errors.Join(errs...) // nil if errs is empty
}
Error with context (custom type)
type RequestError struct {
Method string
URL string
Err error
}
func (e *RequestError) Error() string {
return fmt.Sprintf("%s %s: %v", e.Method, e.URL, e.Err)
}
func (e *RequestError) Unwrap() error {
return e.Err
}
func get(url string) error {
/* ... */
return &RequestError{Method: "GET", URL: url, Err: ioErr}
}
The form admits structured errors with rich context preserved through wrapping.
Don’t ignore errors
// Bad:
result, _ := compute() // discarding error!
// Better — explicitly handle:
result, err := compute()
if err != nil {
log.Printf("compute failed: %v", err)
return defaultValue
}
The blank-identifier discard is sometimes warranted (e.g., Close() in defer where the result is unused), but routine usage is a code smell.
A note on the absence of try/catch
Go does not have try/catch. The principal trade-offs:
- Verbosity — every error must be checked explicitly.
- Visibility — error paths are immediately visible at the call site.
- Stability — errors do not unwind the stack arbitrarily.
- Predictability — control flow is explicit.
The conventional Go style accepts the verbosity in exchange for the visibility. Periodic proposals for syntactic conveniences (a “check” expression analogous to Rust’s ?) have not been accepted as of Go 1.24.
A note on the conventional discipline
The contemporary Go error-handling advice:
- Return errors as the last value.
- Check errors immediately — the next line.
- Wrap with
%wwhen adding context to propagated errors. - Use sentinels (
ErrXxx) for static error values; use types for structured information. - Use
errors.Isanderrors.As— never compare errors with==after Go 1.13. - Use
paniconly for unrecoverable conditions (invariants, init failures). - Use
recoverat boundaries (HTTP handlers, worker goroutines). - Use
deferfor cleanup —Close,Unlock. - Use
errors.Join(Go 1.20+) for multi-error aggregation.
The combination — errors as values, explicit propagation, structured wrapping, sentinel and type-based discrimination, panic/recover for the truly exceptional — is the substance of Go’s error model. The discipline produces explicit, traceable error handling at the cost of verbosity.