Error Handling
Go handles errors explicitly β no exceptions. Errors are values returned alongside results. This deliberate design makes error paths visible, testable, and forces developers to think about failure modes upfront.
π― Learning Objectives
- Understand the
errorinterface and how to create errors - Use sentinel errors for known error conditions
- Create custom error types with additional context
- Wrap and unwrap errors with
fmt.Errorf("%w") - Use
errors.Isanderrors.Asfor error inspection - Know when to use
panicandrecover - Apply production error handling patterns
π¬ The error Interface
The error type is a built-in interface with a single method. Any type that implements Error() string is an error.
// The built-in error interface β defined in the universe block
type error interface {
Error() string
}
// errors.New creates a simple error with a fixed message
import "errors"
err := errors.New("something went wrong")
// fmt.Errorf creates a formatted error message
import "fmt"
name := "config.yaml"
err = fmt.Errorf("file not found: %s", name)package main
import (
"errors"
"fmt"
"os"
)
// Idiomatic: return (value, error) β error is always last
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("division by zero")
}
return a / b, nil // nil error = success
}
func readConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
// Add context when propagating errors up the call stack
return nil, fmt.Errorf("readConfig(%q): %w", path, err)
}
return data, nil
}
func main() {
// Standard error check pattern
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("10 / 2 =", result) // 5
_, err = divide(10, 0)
if err != nil {
fmt.Println("Error:", err) // division by zero
}
// Multiple operations β check each
_, err = readConfig("/nonexistent/config.yaml")
if err != nil {
fmt.Println("Config error:", err)
}
}π Sentinel Errors
Sentinel errors are package-level error values that callers can compare against. They signal well-known error conditions.
package main
import (
"errors"
"fmt"
"io"
"strings"
)
// Define sentinel errors β convention: Err prefix
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
ErrInvalidInput = errors.New("invalid input")
)
type UserStore struct {
users map[int]string
}
func (s *UserStore) GetUser(id int) (string, error) {
name, ok := s.users[id]
if !ok {
return "", ErrNotFound // return the sentinel
}
return name, nil
}
func main() {
store := &UserStore{users: map[int]string{1: "Alice", 2: "Bob"}}
// Compare with errors.Is (works through wrapped errors too)
name, err := store.GetUser(1)
fmt.Println(name, err) // Alice
_, err = store.GetUser(99)
if errors.Is(err, ErrNotFound) {
fmt.Println("User not found β show 404 page")
}
// Standard library sentinel errors
r := strings.NewReader("hello")
buf := make([]byte, 10)
n, err := r.Read(buf)
fmt.Printf("Read %d bytes: %q\n", n, buf[:n])
_, err = r.Read(buf)
if errors.Is(err, io.EOF) {
fmt.Println("End of input reached")
}
} ποΈ Custom Error Types
When you need to carry structured data along with the error (HTTP status codes, field names, retry-after times), implement a custom type satisfying the error interface.
package main
import (
"errors"
"fmt"
"net/http"
"time"
)
// Custom error type β carries HTTP status + message
type HTTPError struct {
Code int
Message string
}
func (e *HTTPError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}
// Custom error with retry information
type RetryableError struct {
Cause error
RetryAfter time.Duration
}
func (e *RetryableError) Error() string {
return fmt.Sprintf("%v (retry after %v)", e.Cause, e.RetryAfter)
}
// Unwrap lets errors.Is/As see through the wrapper
func (e *RetryableError) Unwrap() error {
return e.Cause
}
var ErrRateLimited = errors.New("rate limited")
func fetchData(url string) error {
// Simulate rate-limited API
return &RetryableError{
Cause: ErrRateLimited,
RetryAfter: 30 * time.Second,
}
}
func callAPI(code int) error {
switch code {
case http.StatusNotFound:
return &HTTPError{Code: 404, Message: "resource not found"}
case http.StatusUnauthorized:
return &HTTPError{Code: 401, Message: "please login"}
}
return nil
}
func main() {
err := callAPI(404)
if err != nil {
// errors.As: extract the concrete type from the error chain
var httpErr *HTTPError
if errors.As(err, &httpErr) {
fmt.Printf("Status %d: %s\n", httpErr.Code, httpErr.Message)
if httpErr.Code == 404 {
fmt.Println("Redirecting to home page")
}
}
}
err = fetchData("https://api.example.com/data")
var retryErr *RetryableError
if errors.As(err, &retryErr) {
fmt.Printf("Rate limited. Retry after: %v\n", retryErr.RetryAfter)
}
// errors.Is traverses the Unwrap() chain
if errors.Is(err, ErrRateLimited) {
fmt.Println("Is rate limited: true")
}
}π Error Wrapping β Adding Context
Use fmt.Errorf("...%w", err) to wrap an error. Wrapped errors can be inspected with errors.Is and errors.As at any depth.
package main
import (
"errors"
"fmt"
"os"
)
var ErrConfig = errors.New("configuration error")
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
// Wrap with %w β preserves the original error for Is/As checks
return nil, fmt.Errorf("readFile(%q): %w", path, err)
}
return data, nil
}
func parseConfig(path string) (map[string]string, error) {
_, err := readFile(path)
if err != nil {
// Wrap again β build a chain of context
return nil, fmt.Errorf("parseConfig: %w", err)
}
return map[string]string{}, nil
}
func initApp(cfgPath string) error {
_, err := parseConfig(cfgPath)
if err != nil {
return fmt.Errorf("initApp: %w", err)
}
return nil
}
func main() {
err := initApp("/etc/app/config.yaml")
if err != nil {
// Full error chain with context at each level
fmt.Println("Error:", err)
// Unwrap step by step
fmt.Println("\nUnwrapping chain:")
for e := err; e != nil; e = errors.Unwrap(e) {
fmt.Printf(" β %v\n", e)
}
// Check the root cause
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("\nOS error at path: %q\n", pathErr.Path)
fmt.Printf("Operation: %s\n", pathErr.Op)
}
}
}π₯ Panic & Recover
panic stops normal execution and unwinds the stack. recover catches a panic β it only works inside a defer. Use sparingly: panics are for truly unrecoverable situations (programmer bugs, not user errors).
package main
import "fmt"
// safeDiv: converts a panic into a proper error
func safeDiv(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered panic: %v", r)
}
}()
// This will panic if b == 0 (integer division by zero)
result = a / b
return result, nil
}
// mustCompile: panics on programmer error (invalid regex at startup)
func mustCompile(pattern string) string {
if pattern == "" {
panic("mustCompile: empty pattern")
}
return pattern // simplified
}
// HTTP middleware: recover from panics so the server stays alive
func withRecovery(handler func()) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered from panic: %v β returning 500\n", r)
// In real code: write HTTP 500 response
}
}()
handler()
}
func main() {
// safeDiv wraps the integer division panic
r, err := safeDiv(10, 2)
fmt.Println("10/2 =", r, err) // 5
r, err = safeDiv(10, 0)
fmt.Println("10/0 =", r, err) // 0 recovered panic: integer divide by zero
// Panic in a handler β recovered by middleware
withRecovery(func() {
var m map[string]int // nil map
m["key"] = 1 // will panic: assignment to nil map
})
fmt.Println("Server continues running after panic recovery")
// When to panic:
// β
Programmer errors caught at startup (invalid config, impossible state)
// β
Index out of bounds (Go built-in panic)
// β User input errors β return an error instead
// β Network/IO failures β return an error instead
} - Return an error when the failure is expected or recoverable (IO errors, user input, network issues)
- Panic only for truly unrecoverable programmer bugs (nil receiver, impossible state, violated invariant)
- Libraries should never panic on bad input β return errors so callers can handle them
π€ Interview Questions
errors.Is and errors.As?errors.Is(err, target): traverses the entire error chain (via Unwrap()) and returns true if any error in the chain is equal to target. Used for comparing against sentinel errors. Example: errors.Is(err, io.EOF).
errors.As(err, &target): traverses the error chain and returns true if any error in the chain can be assigned to target (type assertion). Sets target to the matched error so you can access its fields. Used with custom error types. Example: errors.As(err, &httpErr) where httpErr is *HTTPError.
Key difference: Is checks for a specific value (identity); As checks for a specific type (type assertion). Use Is for sentinel errors, As for custom structured error types.
Go's error-as-value approach has several advantages over exceptions: (1) Error paths are visible in the code β you can see every place that might fail; (2) Errors are values β you can store them, pass them, inspect them, and test for them just like any other value; (3) No implicit control flow β exceptions can jump from anywhere to anywhere, making code hard to reason about; (4) Forces developers to think about error cases at the point where they occur; (5) Performance β no exception handling overhead.
Disadvantages: verbose (if err != nil boilerplate); easy to accidentally ignore errors. The Go team has acknowledged the verbosity and future Go versions may address it.
Go does have panic for truly unrecoverable situations, which is analogous to exceptions but reserved for programmer bugs, not user-facing errors.
π§ͺ Quiz
error interface?fmt.Errorf("msg: %w", err) and fmt.Errorf("msg: %v", err)?recover() be called to catch a panic?errors.Is(err, target) instead of err == target?π Cheat Sheet
errors.New("message")fmt.Errorf("ctx: %v", err)fmt.Errorf("ctx: %w", err)errors.Is(err, ErrNotFound)errors.As(err, &target)errors.Unwrap(err)func (e *T) Error() string { }func (e *T) Unwrap() error { return e.cause }panic("unrecoverable bug")defer func() { r := recover() }()var ErrX = errors.New("...")return val, err β error is always last