Error Handling in Go
Go's explicit, value-based approach to errors — no exceptions, no try/catch, just a type and a convention.
3 min read
Go treats errors as ordinary values, not a separate control-flow mechanism like exceptions. This is one of the most opinionated and most-debated design choices in the language, and understanding the convention is essential to writing or reading idiomatic Go.
The error interface
error is just an interface with one method:
type error interface {
Error() string
}Anything with an Error() string method satisfies it. The standard library's simplest implementation is errors.New:
import "errors"
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}The check-immediately convention
Idiomatic Go checks an error the line right after the call that might produce one, before doing anything else with the result:
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("result:", result)This reads as repetitive to newcomers — and it is more verbose than a single top-level try/catch around a big block of code — but the trade-off is deliberate: every place an operation can fail is visible directly in the code, not hidden behind invisible exception propagation that might skip past several stack frames before anything catches it.
Wrapping errors with context
Returning a bare error up several layers of calls often loses the context of where it actually happened. fmt.Errorf with %w wraps an error while preserving the original:
func loadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("loading config from %s: %w", path, err)
}
// ...
return nil
}Each layer that wraps an error adds its own context, so the final error message reads like a trail: "loading config from app.yaml: open app.yaml: no such file or directory" — far more useful for debugging than the bare original.
Inspecting wrapped errors
var ErrNotFound = errors.New("not found")
func findUser(id int) error {
return fmt.Errorf("finding user %d: %w", id, ErrNotFound)
}
err := findUser(42)
if errors.Is(err, ErrNotFound) {
fmt.Println("user doesn't exist")
}errors.Is checks whether a specific sentinel error appears anywhere in a chain of wrapped errors — a direct == comparison would fail here since err isn't literally ErrNotFound, just wraps it. For extracting a specific custom error type out of a chain, errors.As does the equivalent job:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println("field:", validationErr.Field)
}Custom error types
For errors that need to carry structured data, define a type that satisfies the error interface:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}
func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Msg: "cannot be negative"}
}
return nil
}The rule of thumb
Never ignore an error with a bare _ unless you have a specific, deliberate reason (and even then, a comment explaining why is good practice). Return errors up to whoever can actually decide what to do about them, wrapping with context as they pass through layers that have useful information to add. And remember panic (from the earlier lesson) is reserved for genuinely unrecoverable situations — the error return value is the tool for everything else, which in practice means almost everything.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.