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.
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.
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.
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.
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.
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.
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)
}
}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.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 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 defaults — http.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.
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).