thecodex.expert · The Codex Family of Knowledge
Go

Error Handling and Stdlib

Errors as values, not exceptions — and a standard library so complete you rarely need to reach for a third-party package.

Go 1.26 No exceptions Last verified:
Canonical Definition

Go treats errors as values — functions return (result, error) and callers check if err != nil; there are no exceptions. The error interface has one method (Error() string). Errors can be wrapped with fmt.Errorf("%w", err) and inspected with errors.Is / errors.As for type-safe error chain traversal.

The central idea

Most languages use exceptions: code throws, something else catches, and the control flow jumps. Go rejects this. A function that can fail returns a second value of type error. If everything worked, the error is nil. If something failed, it describes what. Callers are forced to decide what to do with failures at the call site — no hidden control flow.

The error interface

In Go, error is just an interface with one method. Anything that has an Error() string method is an error. The standard library's errors.New and fmt.Errorf are the most common ways to create errors.

Goerrors.go
package main

import (
    "errors"
    "fmt"
)

// The error interface — this is ALL it is:
// type error interface {
//     Error() string
// }

// errors.New creates a simple error value
var ErrNotFound = errors.New("not found")

// Functions that can fail return (value, error)
func findUser(id int) (string, error) {
    users := map[int]string{1: "Alice", 2: "Bob"}
    name, ok := users[id]
    if !ok {
        return "", fmt.Errorf("findUser: id %d: %w", id, ErrNotFound)
        // %w wraps the error — allows errors.Is to unwrap the chain
    }
    return name, nil
}

func main() {
    name, err := findUser(1)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Found:", name)  // Found: Alice

    _, err = findUser(99)
    if err != nil {
        fmt.Println("Error:", err)  // findUser: id 99: not found

        // errors.Is checks the error chain for a specific value
        if errors.Is(err, ErrNotFound) {
            fmt.Println("It was a not-found error")
        }
    }
}

The net/http package — a server in 10 lines

Go's standard library includes a production-quality HTTP server. net/http handles HTTP/1.1 and HTTP/2, TLS, connection management, and request routing. Every incoming request is handled in its own goroutine automatically.

Goserver.go
package main

import (
    "encoding/json"
    "fmt"
    "net/http"
)

type Response struct {
    Message string `json:"message"`
    Status  int    `json:"status"`
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
    // Set headers before writing body
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(http.StatusOK)

    resp := Response{Message: "Hello, World!", Status: 200}
    json.NewEncoder(w).Encode(resp)
}

func main() {
    http.HandleFunc("/hello", helloHandler)
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Go HTTP server running")
    })

    fmt.Println("Listening on :8080")
    // ListenAndServe blocks forever; returns only on error
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Println("Server error:", err)
    }
}

Custom error types and errors.As

For structured error information (HTTP status codes, file paths, query strings), define a custom error type. errors.As checks the error chain for a specific concrete type and extracts it — type-safe error handling without type assertions.

Gocustomerrors.go
package main

import (
    "errors"
    "fmt"
)

// Custom error type with structured data
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error: field %q: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{Field: "age", Message: "must be non-negative"}
    }
    if age > 150 {
        return &ValidationError{Field: "age", Message: "unrealistically large"}
    }
    return nil
}

func processUser(name string, age int) error {
    if err := validateAge(age); err != nil {
        // Wrap the error with context — preserves the original error in the chain
        return fmt.Errorf("processUser %q: %w", name, err)
    }
    return nil
}

func main() {
    err := processUser("Alice", -5)
    if err != nil {
        fmt.Println(err)  // processUser "Alice": validation error: field "age": must be non-negative

        // errors.As: extract specific concrete error type from the chain
        var ve *ValidationError
        if errors.As(err, &ve) {
            fmt.Printf("Failed field: %s\n", ve.Field)
            fmt.Printf("Reason: %s\n", ve.Message)
        }
    }
}

encoding/json — serialisation

The encoding/json package marshals Go structs to JSON and unmarshals JSON to structs. Struct field tags control the JSON key name, omitempty behaviour, and type coercion.

Gojson.go
package main

import (
    "encoding/json"
    "fmt"
)

type User struct {
    ID       int    `json:"id"`
    Name     string `json:"name"`
    Email    string `json:"email,omitempty"` // omit if empty
    Password string `json:"-"`               // never serialise
}

func main() {
    // Marshal: struct -> JSON
    u := User{ID: 1, Name: "Alice", Email: "alice@example.com", Password: "secret"}
    data, err := json.Marshal(u)
    if err != nil {
        fmt.Println("marshal error:", err)
        return
    }
    fmt.Println(string(data))
    // {"id":1,"name":"Alice","email":"alice@example.com"}
    // Password is omitted; field name is "name" not "Name"

    // Unmarshal: JSON -> struct
    jsonStr := `{"id":2,"name":"Bob"}`
    var u2 User
    if err := json.Unmarshal([]byte(jsonStr), &u2); err != nil {
        fmt.Println("unmarshal error:", err)
        return
    }
    fmt.Printf("%+v\n", u2)  // {ID:2 Name:Bob Email: Password:}
    // Email is empty string (zero value) since it was absent in JSON
}

Built-in testing

Go has a first-class test framework built into the standard library and toolchain. No test runner to install. go test finds and runs all *_test.go files. Test functions take a *testing.T. Benchmarks take a *testing.B. Table-driven tests are the Go idiom for testing multiple inputs.

Goadd_test.go
package main

import "testing"

func Add(a, b int) int { return a + b }

// Table-driven test — the Go idiom
func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 1, 2, 3},
        {"negative", -1, -2, -3},
        {"zero", 0, 0, 0},
        {"mixed", 5, -3, 2},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}

// Benchmark: run with go test -bench=.
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(1, 2)
    }
}
Commonly confused
Go has no exceptions — panic is not an exception mechanism for normal errors. panic is for unrecoverable programmer errors (index out of bounds, nil dereference, assertion failures). Normal errors are returned as values. Using panic for control flow is a code smell in Go; using it across package boundaries is a serious design error.
errors.Is and errors.As traverse the error chain; == does not. If an error was wrapped with fmt.Errorf("%w", err), comparing with err == ErrNotFound returns false — the outer error is a different value. errors.Is(err, ErrNotFound) unwraps the chain and checks each layer. Use errors.Is for sentinel errors, errors.As for concrete types.
defer runs when the surrounding function returns, not when the block ends. defer f() schedules f to run when the enclosing function exits (whether normally or via panic). Multiple defers run LIFO (last in, first out). This is ideal for cleanup: defer file.Close() immediately after os.Open, so you can't forget it.

Error handling design: values vs exceptions

Go's choice to make errors explicit return values is deliberate and has academic support in the "errors as values" philosophy articulated by Rob Pike (go.dev/blog/errors-are-values). The core argument: exceptions introduce invisible control flow — any function call might throw, requiring callers to reason about which functions might throw and what state they leave behind. Go's explicit errors make failure modes visible at the call site. The cost is verbosity (if err != nil repeated); Go 2 proposals for error handling sugar have been discussed but not adopted as of 1.23, as the core team has been cautious about adding complexity to the error handling model.

The errors package's wrapping mechanism (added in Go 1.13) was designed to solve a specific problem: adding context to errors as they propagate up the call stack while preserving the ability to inspect the original error. The %w verb in fmt.Errorf stores the wrapped error; errors.Unwrap retrieves it; errors.Is and errors.As traverse the full unwrap chain. Custom error types can implement Unwrap() error (or Unwrap() []error for multiple wrapped errors, added in Go 1.20) to participate in the chain.

Standard library design philosophy

Go's standard library embodies several design principles. First, accept interfaces, return structs — functions accepting io.Reader or io.Writer work with any data source or sink; functions returning concrete types give callers full access to the type's capabilities. Second, zero configuration defaultshttp.ListenAndServe(":8080", nil) uses the default mux; logging, JSON encoding, and cryptography packages work with sensible defaults. Third, no global state — the standard library is designed so that multiple instances can coexist in the same process without interference. The standard library is also conservative: packages are only added when the Go team is confident the API will not need breaking changes. net/http/httptest, math/rand/v2, and slices/maps (1.21) are recent additions; the log/slog structured logging package was added in Go 1.21 after years of debate.

Specification reference

The Go Team. The Go Programming Language Specification. go.dev/ref/spec. Sections: "Errors", "Defer statements", "Handling panics". Go standard library: pkg.go.dev/std — errors, fmt, net/http, encoding/json, testing, sync, context. Pike, R. (2015). Errors are values. go.dev/blog/errors-are-values. Donovan & Kernighan (2015). The Go Programming Language. Chapter 5 (Functions), Chapter 7.11 (Error interface).

Sources

1
The Go Team. The Go Programming Language Specification. go.dev/ref/spec. — Errors, defer, panic, and recover.
2
Go standard library documentation. pkg.go.dev/std. — errors, fmt, net/http, encoding/json, testing packages.
3
Pike, R. (2015). Errors are values. go.dev/blog/errors-are-values. — The design rationale for Go's error model.
4
Donovan, A. A. A. & Kernighan, B. W. (2015). The Go Programming Language. Addison-Wesley. — Chapters 5 and 7 on functions and error handling.
5
Go Blog. Working with Errors in Go 1.13. go.dev/blog/go1.13-errors. — The %w verb, errors.Is, and errors.As.
Source confidence: High Last verified: Primary source: Go standard library — pkg.go.dev/std