Lesson 2: Go Error Handling: The error Interface
Why Error Handling Matters in Go
// Real Kubernetes code. Notice anything?
pod, err := c.clientset.CoreV1().Pods(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
if errors.Is(err, ErrPodNotFound) {
return c.handleMissingPod(ctx, name)
}
return fmt.Errorf("getting pod %s/%s: %w", namespace, name, err)
}
This pattern — check, wrap, return — appears in every single cloud-native Go project. Kubernetes has tens of thousands of error checks. Docker, Prometheus, Terraform — same pattern everywhere.
There are no exceptions. No try/catch. Errors are values that implement an interface. If you understood Lesson 0001, you already understand the foundation.
Understand Go's error model deeply — sentinel errors, wrapping, errors.Is/errors.As, custom error types, and real-world patterns from cloud-native projects.
The error Interface
Go's error type is just a single-method interface. Nothing more.
type error interface {
Error() string
}
This is the exact same interface pattern from Lesson 0001. Anything with an Error() string method is an error. The standard library, your code, third-party libraries — they all return this one interface type.
Because it's just an interface, nil means "no error". When a function succeeds, it returns nil for the error. When it fails, it returns something that satisfies the error interface.
// Idiomatic Go: last return value is error
func DoSomething() (Result, error) {
if somethingWrong {
return Result{}, errors.New("something went wrong")
}
return Result{...}, nil
}
// Caller MUST check the error
result, err := DoSomething()
if err != nil {
// handle it
}
The nil interface trap applies here. An error interface holding a nil concrete pointer is NOT nil. We'll revisit this below.
Creating Errors
Simple Errors with errors.New
import "errors"
err := errors.New("connection refused")
Formatted Errors with fmt.Errorf
import "fmt"
err := fmt.Errorf("failed to connect to %s:%d", host, port)
Sentinel Errors
Package-level errors that callers can check against. Name them ErrXxx.
// Define at package level
var ErrNotFound = errors.New("not found")
var ErrTimeout = errors.New("operation timed out")
var ErrInvalid = errors.New("invalid argument")
// In your function
func FindUser(id int) (*User, error) {
user, ok := db[id]
if !ok {
return nil, ErrNotFound
}
return user, nil
}
Why sentinels? Callers can branch on the specific error rather than parsing error strings. "Is this error a 'not found'?" becomes errors.Is(err, ErrNotFound) instead of err.Error() == "not found".
Wrapping Errors — Preserving the Chain
Raw error propagation loses context. Wrapping preserves the original error while adding information:
// Go 1.13+: the %w verb wraps an error
func ReadConfig(path string) (Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return Config{}, fmt.Errorf("reading config %s: %w", path, err)
}
// ...
}
Now the caller can:
- Print the full chain:
reading config /etc/app.yaml: open /etc/app.yaml: permission denied - Check the root cause:
errors.Is(err, os.ErrPermission)returns true - Extract the underlying type:
errors.As(err, &pathErr)
// Unwrap one level
cause := errors.Unwrap(err)
// %v prints the message; %+v (some libraries) prints the stack
fmt.Printf("%v\n", err)
Only wrap once per layer. Don't wrap an already-wrapped error — that creates duplicate context. Each function that adds context wraps once: fmt.Errorf("my context: %w", errFromBelow).
errors.Is and errors.As
These are the two tools for inspecting errors. Never compare error strings directly.
errors.Is — Check for a specific error value
// errors.Is walks the error chain and checks equality (==)
err := someFunction()
if errors.Is(err, io.EOF) {
// handle end-of-file
}
if errors.Is(err, ErrNotFound) {
// handle not found
}
Always use errors.Is instead of err == io.EOF. If the error was wrapped, == will fail; errors.Is unwraps the chain.
errors.As — Extract a specific error type
// errors.As walks the chain and checks if any error matches the target type
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Printf("operation: %s, path: %s\n", pathErr.Op, pathErr.Path)
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
fmt.Printf("DNS error: %s, isTemporary: %v\n", dnsErr.Name, dnsErr.IsTemporary)
}
FunctionChecksUse case
errors.Is(err, target)Equality through chainSentinel errors
errors.As(err, &target)Type match through chainCustom error types
Custom Error Types
When you need to attach structured data to an error, define a struct that implements the error interface:
type ValidationError struct {
Field string
Value interface{}
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s (got %v)", e.Field, e.Message, e.Value)
}
// Caller extracts the type:
var verr *ValidationError
if errors.As(err, &verr) {
fmt.Printf("Field %s is invalid\n", verr.Field)
}
Implementing Unwrap and Is
// If your custom error wraps another error, implement Unwrap
type QueryError struct {
Query string
Cause error
}
func (e *QueryError) Error() string {
return fmt.Sprintf("query %q failed: %v", e.Query, e.Cause)
}
func (e *QueryError) Unwrap() error {
return e.Cause // enables errors.Is/As to walk through
}
When callers need to extract structured information from the error (field name, HTTP status code, retryable flag). Otherwise, a sentinel error or fmt.Errorf is simpler and better.
Error Handling Patterns
1. Add context at each layer
func StartServer(cfg Config) error {
listener, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return fmt.Errorf("starting server on %s: %w", cfg.Addr, err)
}
// ...
}
Each function adds its own context. The top-level main function prints the full chain. The user sees: starting server on :8080: listen tcp :8080: address already in use.
2. Handle errors, don't just log and return
// Bad: double-logging
data, err := os.ReadFile(path)
if err != nil {
log.Printf("error reading %s: %v", path, err)
return err
}
// Good: wrap and return — let the top level decide how to log
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
3. Defer with error capture
func ProcessFile(path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
cerr := f.Close()
if err == nil {
err = cerr // capture close error only if no prior error
}
}()
// ... use f
}
4. The nil interface trap (from Lesson 0001)
func getError() error {
var p *MyError // nil pointer
return p // returned error is NOT nil!
}
err := getError()
if err != nil { // true — even though p is nil
// This branch executes. The interface holds (type=*MyError, value=nil)
}
Fix: return nil explicitly, not a typed nil:
func getError() error {
var p *MyError // nil pointer
if p == nil {
return nil // correct: untyped nil
}
return p
}
Error Handling in Cloud-Native Go
Kubernetes: machine-readable errors
// Kubernetes wraps API errors in StatusError
type StatusError struct {
ErrStatus metav1.Status // contains Code, Reason, Message, Details
}
func (e *StatusError) Error() string {
return e.ErrStatus.Message
}
// Controllers check for specific status codes
if apierrors.IsNotFound(err) {
// resource doesn't exist — skip reconciliation
}
if apierrors.IsConflict(err) {
// concurrent modification — retry
}
Prometheus: structured errors for metrics
// Prometheus uses sentinel errors for scrape results
var ErrDuplicateLabel = errors.New("duplicate label name")
var ErrLabelNameInvalid = errors.New("invalid label name")
var ErrNoMetricName = errors.New("metric name missing")
// Uses custom error types for structured scrape errors
type ScrapeError struct {
Target string
Err error
Duration time.Duration
}
When you contribute a bugfix to Kubernetes, you'll likely need to add or modify an error check. Understanding errors.Is, errors.As, and wrapping means you can read and write error handling that passes code review in these projects.
Practice
Define a package-level sentinel error ErrInsufficientFunds. Write a function Withdraw(balance float64, amount float64) (float64, error) that:
- Returns the new balance if amount <= balance
- Returns
ErrInsufficientFundsif amount > balance Inmain(), callWithdrawand useerrors.Isto check if the error isErrInsufficientFunds.
Write two functions:
OpenDB(dsn string) (*sql.DB, error)— simulates opening a database. Returnsfmt.Errorf("connection refused: %s", dsn).InitApp() error— callsOpenDBand wraps its error with context:fmt.Errorf("init: %w", err). Inmain(), print the full error chain using%v. It should show both messages.
Define a custom error type RetryableError with fields:
Operation stringAttempts intCause errorImplementError() stringandUnwrap() error. Then write a functionRetryWithBackoff(fn func() error, maxRetries int) errorthat:
- Calls
fn()up tomaxRetriestimes - If it fails all attempts, returns a
RetryableError - In
main(), useerrors.Asto extract theRetryableErrorand print the number of attempts Test it with a function that always returnserrors.New("transient failure").
Quick Check
What's Next?
You now understand Go's error model — errors are values, wrapping preserves chains, and errors.Is/errors.As inspect them. Next up: Struct Tags, JSON & Protobuf — how Go uses struct tags to drive serialization in cloud-native APIs.
Ask Questions
Paste your exercise code and I'll review it. Confused about when to use sentinels vs custom types? Ask.
1. Q1: Why should you use errors.Is(err, io.EOF) instead of err == io.EOF?
2. Q2: What's wrong with this code?
3. Q3: Which verb wraps an error in fmt.Errorf?