Error Handling
Learn how Go handles errors as ordinary values using the error interface, the if err != nil pattern, and why Go deliberately avoids exceptions.
Introduction
Most languages handle failure with exceptions: you throw an error somewhere deep in a call stack and hope something up above catches it. Go takes a very different, and very deliberate, approach. In Go, an error is just an ordinary value returned alongside a function's normal result. There is no hidden control flow, no try/catch, and no surprise about where a failure might surface. If a function can fail, its signature says so, and the caller is expected to check it immediately.
- What the built-in error interface looks like.
- How to check errors with the idiomatic if err != nil pattern.
- How to create your own errors with errors.New and fmt.Errorf.
- How to wrap and unwrap errors to preserve context.
- Why Go's designers chose values over exceptions.
The error Interface
error is one of the simplest interfaces in the standard library. It has exactly one method: Error() string. Anything that implements that method satisfies the error interface, which means you can build your own custom error types as easily as you build any other type.
type error interface { Error() string}By convention, functions that can fail return an error as their last return value. A nil error means "everything went fine"; a non-nil error means something went wrong, and its Error() string describes what.
The if err != nil Pattern
The idiomatic Go pattern is to call a function, immediately check its returned error, and handle it before doing anything else with the result. This keeps failure handling local and explicit instead of scattered across a call stack.
package main
import ( "fmt" "strconv")
func main() { input := "42"
n, err := strconv.Atoi(input) if err != nil { fmt.Println("conversion failed:", err) return }
fmt.Println("parsed number:", n)
// Now try a value that cannot be converted. _, err = strconv.Atoi("not-a-number") if err != nil { fmt.Println("conversion failed:", err) return }}Click Run to see what this code prints.
Notice there is no exception mechanism here at all. strconv.Atoi simply returns two values: the parsed integer and an error. If the error is non-nil, the integer value is meaningless and should be ignored.
Creating Errors
The standard library gives you two easy ways to create your own errors: errors.New for a static message, and fmt.Errorf when you need to format a message dynamically.
package main
import ( "errors" "fmt")
func divide(a, b float64) (float64, error) { if b == 0 { return 0, errors.New("division by zero") } return a / b, nil}
func withdraw(balance, amount float64) (float64, error) { if amount > balance { return balance, fmt.Errorf("insufficient funds: have %.2f, need %.2f", balance, amount) } return balance - amount, nil}
func main() { if _, err := divide(10, 0); err != nil { fmt.Println("error:", err) }
if _, err := withdraw(100, 250); err != nil { fmt.Println("error:", err) }}Click Run to see what this code prints.
You can also define a completely custom error type by implementing the Error() string method. This is useful when callers need to programmatically inspect fields on the error, not just read a message.
type ValidationError struct { Field string Msg string}
func (e *ValidationError) Error() string { return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Msg)}
func validateAge(age int) error { if age < 0 { return &ValidationError{Field: "age", Msg: "cannot be negative"} } return nil}Wrapping Errors
Sometimes a low-level error needs extra context as it travels up through several layers of a program. fmt.Errorf supports a special %w verb that wraps an existing error while preserving it, so callers can later unwrap it to find the original cause with errors.Is and errors.As.
package main
import ( "errors" "fmt")
var ErrNotFound = errors.New("record not found")
func findUser(id int) error { if id != 1 { return fmt.Errorf("findUser(%d): %w", id, ErrNotFound) } return nil}
func main() { err := findUser(42) if err != nil { fmt.Println(err)
if errors.Is(err, ErrNotFound) { fmt.Println("this is a not-found error") } }}Click Run to see what this code prints.
Use errors.Is when you want to check "is this error, or does it wrap, a specific sentinel error?" Use errors.As when you want to extract a specific error type out of a wrapped chain so you can access its fields.
Why Go Avoids Exceptions
Go does have panic and recover (covered in the next lesson), but they are reserved for truly exceptional, unrecoverable situations, not everyday error handling. The designers of Go chose explicit error values over exceptions for a few concrete reasons: control flow stays visible in the code you are reading, every call site is forced to at least acknowledge that failure is possible, and there is no risk of an exception silently unwinding through several layers of unrelated code before anyone notices.
The tradeoff is more visual repetition — you will type if err != nil a lot — but in exchange you get code where failure handling is never a surprise.
Common Mistakes
- Ignoring an error by assigning it to _ without a good reason.
- Checking err but then still using the zero-value result as if it were valid.
- Comparing error messages with strings.Contains instead of using errors.Is/errors.As.
- Wrapping every single error with excessive context, making messages unreadable.
- Using panic for ordinary, expected failure conditions like "file not found".
Best Practices
- Check errors immediately after the call that can produce them.
- Add context with fmt.Errorf("doing X: %w", err) rather than swallowing the original error.
- Define sentinel errors (var ErrX = errors.New(...)) for conditions callers may want to check.
- Keep error messages lowercase and without trailing punctuation, per Go convention.
- Return errors up the stack instead of logging and continuing as if nothing happened.
Frequently Asked Questions
No, by convention a function returns at most one error as its final return value. If you need multiple failures reported at once, return a slice of errors or use errors.Join (Go 1.20+).
Occasionally, such as ignoring the error from fmt.Println. Even then it is good practice to be explicit, e.g. _ = someCall(), so it is clear the omission was intentional.
errors.New creates a static error with a fixed message. fmt.Errorf lets you format a dynamic message and, with %w, wrap another error inside it.
Key Takeaways
- Errors in Go are ordinary values that satisfy the built-in error interface.
- The idiomatic pattern is to check if err != nil immediately after a call.
- errors.New and fmt.Errorf create simple and formatted errors respectively.
- fmt.Errorf with %w wraps an error so it can be inspected later with errors.Is/errors.As.
- Go favors explicit error values over exceptions to keep control flow visible.
Summary
Go treats errors as first-class values instead of hidden control flow. By checking errors where they occur, creating them with errors.New or fmt.Errorf, and wrapping them for context, you write programs where failure is explicit, traceable, and never a surprise. Next, you will learn about panic, recover, and defer — the tools Go reserves for truly exceptional situations.