Go’s error handling is verbose by design. The verbosity is the point — explicit failure paths. The patterns that make it work in production are well-established. This post is the working set.
The basics
func loadConfig(path string) (*Config, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
defer f.Close()
var cfg Config
if err := json.NewDecoder(f).Decode(&cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
return &cfg, nil
}
%w wraps. Caller can unwrap and inspect. Without %w, the chain is broken.
Sentinels
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
)
func GetUser(id int64) (*User, error) {
if !exists(id) {
return nil, ErrNotFound
}
...
}
// Caller
user, err := GetUser(id)
if errors.Is(err, ErrNotFound) {
// 404
}
Cheap, idiomatic. Works through wrapping (errors.Is traverses the chain).
Custom error types
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}
// Caller
var ve *ValidationError
if errors.As(err, &ve) {
log.Printf("invalid field: %s", ve.Field)
}
For errors that carry data. errors.As finds the wrapped instance.
Combining
A package can have both:
var ErrNotFound = errors.New("not found")
type DBError struct { Op string; Err error }
func (e *DBError) Error() string { return fmt.Sprintf("%s: %v", e.Op, e.Err) }
func (e *DBError) Unwrap() error { return e.Err }
func loadUser(id int64) (*User, error) {
err := db.QueryRow(...).Scan(...)
if err == sql.ErrNoRows {
return nil, &DBError{Op: "loadUser", Err: ErrNotFound}
}
if err != nil {
return nil, &DBError{Op: "loadUser", Err: err}
}
...
}
// Caller still works:
if errors.Is(err, ErrNotFound) { ... }
Custom type with Unwrap enables errors.Is through the chain.
errors.Join
err := errors.Join(closeReader(), closeWriter(), syncFs())
if err != nil {
return fmt.Errorf("cleanup: %w", err)
}
For multiple errors that all matter (cleanup, multi-step ops). errors.Is works on each.
fmt.Errorf vs errors.New
errors.New("simple message") // package-level sentinel
fmt.Errorf("loading %s: %w", path, err) // formatted + wrapped
Use errors.New for sentinels. fmt.Errorf("...: %w", err) for context + wrap.
When to wrap
Wrap when you add context that’s not in the original error:
// Good
return fmt.Errorf("loading user %d: %w", userID, err)
// Bad — adds nothing
return fmt.Errorf("error: %w", err)
The chain becomes a story: “loading user 42: querying users table: connection refused.”
When NOT to wrap
If the caller will only check sentinel/type, wrapping is fine. If the inner error is sensitive (e.g., reveals SQL), don’t wrap — replace with a generic public error and log the inner.
// Internal: log full
log.Errorf("internal: %v", innerErr)
// Public: generic
return fmt.Errorf("internal server error")
HTTP error mapping
type appError struct {
Code int
Message string
}
func (e appError) Error() string { return e.Message }
func handleError(w http.ResponseWriter, err error) {
var ae appError
if errors.As(err, &ae) {
http.Error(w, ae.Message, ae.Code)
return
}
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
return
}
log.Printf("internal error: %v", err)
http.Error(w, "internal", 500)
}
One function maps internal errors to HTTP responses. Handlers stay clean.
Repeated if err != nil { return err }
It IS verbose. The community has accepted that the verbosity is worth the explicitness. Tools that “fix” it (early-return generics, etc.) haven’t won.
What helps:
- Wrap once at the boundary that adds value.
- Structured logging so the error chain prints well.
- Group steps that all fail together — sometimes a helper function reduces noise.
panic / recover
func init() {
pool, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatal(err) // not panic — log.Fatal exits
}
DB = pool
}
For startup-only unrecoverable errors: log.Fatal is idiomatic.
In handlers:
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v\n%s", r, debug.Stack())
http.Error(w, "internal", 500)
}
}()
Recover at the boundary so one bad handler doesn’t kill the whole server. But — fix the panic; don’t normalize them.
Common mistakes
1. Swallowing errors
result, _ := someOp() // BAD
Discards information. Always handle or log.
2. Comparing strings
if err.Error() == "not found" { ... } // BAD
Brittle. Use sentinels with errors.Is.
3. Forgetting to wrap
return err // chain broken; "what went wrong" lost
Add context: return fmt.Errorf("opX: %w", err).
4. Custom type without Unwrap
type MyErr struct { Inner error }
func (e *MyErr) Error() string { return e.Inner.Error() }
// MISSING Unwrap()
errors.Is won’t traverse. Always implement Unwrap when wrapping.
5. Panicking on recoverable errors
User input invalid → panic. Bad. Return error; let caller decide.
What I’d ship today
For a Go service:
- Sentinels for known error types.
- Custom types when carrying context.
- Wrap with
%wat boundaries. errors.Is/errors.Asat decision points.- One mapping function for HTTP / gRPC error codes.
- Structured logging that prints the full error chain.
- Defer + recover at request boundary.
Read this next
- Go Concurrency: Goroutines & Channels
- Go Context and Cancellation 2026
- Go Testing Patterns 2026
- Go REST API with net/http
If you want my Go error handling reference (sentinels + types + HTTP mapping), it’s at rajpoot.dev .
Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .