Error Handling Best Practices in Go
Write idiomatic Go error handling — wrapping, sentinel errors, custom error types, errors.Is, errors.As, and patterns for clean error flows.
What you'll learn
- ✓Why Go uses explicit error returns instead of exceptions
- ✓Wrapping errors with fmt.Errorf and %w
- ✓Sentinel errors and custom error types
- ✓Inspecting error chains with errors.Is and errors.As
Prerequisites
- •Go basics (functions, structs, interfaces)
- •Familiarity with the error interface
Go handles errors through explicit return values, not exceptions. This design forces you to think about failure at every call site. Here is how to do it well.
The error interface
type error interface {
Error() string
}
Any type with an Error() string method satisfies the interface. The standard library’s errors.New and fmt.Errorf create simple error values.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil
}
The if err != nil pattern
Check errors immediately after the call. This is the most common pattern in Go.
result, err := divide(10, 0)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
Do not ignore errors silently. If a function returns an error, handle it or deliberately discard it with a comment explaining why.
Wrapping errors
Use fmt.Errorf with %w to add context while preserving the original error.
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config %s: %w", path, err)
}
return data, nil
}
The %w verb wraps the error so callers can unwrap and inspect the chain.
Adding context at every layer
func loadApp() error {
cfg, err := readConfig("app.yaml")
if err != nil {
return fmt.Errorf("loading app: %w", err)
}
// use cfg...
return nil
}
The resulting error message reads like a stack: "loading app: reading config app.yaml: open app.yaml: no such file or directory".
Sentinel errors
A sentinel error is a package-level variable you compare against.
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
func findUser(id string) (*User, error) {
user, ok := users[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
Checking with errors.Is
errors.Is walks the error chain to find a match.
user, err := findUser("123")
if errors.Is(err, ErrNotFound) {
http.Error(w, "user not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
Never compare with == — it does not unwrap wrapped errors.
Custom error types
For errors that carry structured data, define a type.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation: %s — %s", e.Field, e.Message)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{
Field: "age",
Message: "must be between 0 and 150",
}
}
return nil
}
Checking with errors.As
errors.As finds the first error in the chain that matches a target type.
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("field %s is invalid: %s\n", ve.Field, ve.Message)
}
Handling multiple errors
Go 1.20+ supports wrapping multiple errors with errors.Join.
func validateUser(u User) error {
var errs []error
if u.Name == "" {
errs = append(errs, &ValidationError{Field: "name", Message: "required"})
}
if u.Age < 0 {
errs = append(errs, &ValidationError{Field: "age", Message: "must be positive"})
}
return errors.Join(errs...)
}
Patterns for clean error flow
Early return
func processOrder(id string) error {
order, err := fetchOrder(id)
if err != nil {
return fmt.Errorf("fetching order: %w", err)
}
if err := validateOrder(order); err != nil {
return fmt.Errorf("validating order: %w", err)
}
if err := chargePayment(order); err != nil {
return fmt.Errorf("charging payment: %w", err)
}
return nil
}
Defer for cleanup
func writeFile(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = fmt.Errorf("closing file: %w", cerr)
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("writing data: %w", err)
}
return nil
}
Summary
Go error handling is verbose by design — it forces you to confront failures. Wrap errors with %w to build useful context chains. Use sentinel errors for known conditions, custom types for structured data, and errors.Is/errors.As to inspect them. Keep your error paths short with early returns.
Related articles
- Go Error Wrapping Strategies in Go
Master Go error wrapping with fmt.Errorf %w, errors.Is, errors.As, and custom error types for clear, debuggable error chains.
- Go The context Package in Go
Master Go's context package — cancellation, timeouts, deadlines, values, and how to propagate them through your application stack.
- Go Go Interfaces Explained
Understand Go interfaces — implicit satisfaction, the empty interface, type assertions, interface composition, and real design patterns.
- Go Testing Strategies in Go
Write effective Go tests — table-driven tests, subtests, test helpers, mocking with interfaces, benchmarks, and integration testing patterns.