In Go, errors are values — ordinary return values that you check like any other variable. There are no exceptions, no try/catch, no automatic crashes. If something can fail, the function returns an error alongside the result, and your code is expected to look at it.

Some developers find this verbose. Others find it the clearest, most explicit error handling in any language. Either way, it’s how Go works, and you’ll write a lot of if err != nil in your career.

The error type

error is an interface in Go’s standard library:

type error interface {
    Error() string
}

Any value with an Error() string method is an error. The actual error you get back is some concrete type (often a struct) that satisfies this interface.

The classic pattern

package main

import (
    "fmt"
    "strconv"
)

func main() {
    n, err := strconv.Atoi("42")
    if err != nil {
        fmt.Println("Conversion failed:", err)
        return
    }
    fmt.Println("Got number:", n)

    n, err = strconv.Atoi("not a number")
    if err != nil {
        fmt.Println("Conversion failed:", err)
        return
    }
    fmt.Println("Got number:", n)
}
Got number: 42
Conversion failed: strconv.Atoi: parsing "not a number": invalid syntax

Read it carefully — the pattern is everywhere in Go:

  1. Call a function that can fail: n, err := strconv.Atoi(...)
  2. Immediately check the error: if err != nil
  3. Handle the error (return, log, etc.)
  4. Otherwise, use the value

strconv.Atoi (“ASCII to int”) parses a string to a number. The first call succeeds — err is nil. The second fails — err holds an error describing the problem.

Returning errors from your own functions

Make your function return (value, error) — a pair where the error is nil on success:

package main

import (
    "errors"
    "fmt"
)

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("cannot divide by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 2)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)

    result, err = divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}
Result: 5
Error: cannot divide by zero

errors.New("...") creates a basic error from a message. It’s the simplest way to return one.

Convention: when you return an error, return a “zero” value for the other returns — 0, "", nil, etc. Callers should never use the value when the error is non-nil. Returning a sensible zero is a small kindness if they accidentally do.

fmt.Errorf — errors with formatted messages

When you want to include details in your error message, use fmt.Errorf:

// inside main()
name := "users.csv"
err := fmt.Errorf("could not open file %q: permission denied", name)
fmt.Println(err)
could not open file "users.csv": permission denied

%q wraps the value in quotes — perfect for filenames, identifiers, and anything else where quoting helps clarity.

Wrapping errors

When you catch an error from a lower-level function and pass it up, you usually want to add context without losing the original error. Use %w in fmt.Errorf:

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("not found")

func loadUser(id int) error {
    return fmt.Errorf("loadUser(%d): %w", id, ErrNotFound)
}

func main() {
    err := loadUser(42)
    fmt.Println(err)

    if errors.Is(err, ErrNotFound) {
        fmt.Println("It was a not-found error")
    }
}
loadUser(42): not found
It was a not-found error

Two important pieces:

  1. %w wraps an existing error. The new error includes the message but also remembers the original.
  2. errors.Is(err, target) checks if err is, or wraps, the target error. This is how you check for specific error conditions across layers of code.

errors.Is is the modern, correct way to compare errors. Don’t use err == ErrNotFound — it’ll fail when the error has been wrapped.

Custom error types

Sometimes you want errors that carry data, not just a message. Define a struct that has an Error() string method:

package main

import "fmt"

type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s: %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: "must be realistic"}
    }
    return nil
}

func main() {
    if err := validateAge(-5); err != nil {
        fmt.Println(err)
    }
    if err := validateAge(200); err != nil {
        fmt.Println(err)
    }
    if err := validateAge(30); err == nil {
        fmt.Println("Age 30 is valid")
    }
}
validation: age: must be non-negative
validation: age: must be realistic
Age 30 is valid

Now callers can do more than read a string — they can extract structured data using a type assertion or errors.As:

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println("Bad field:", ve.Field)
}

Sentinel errors

A sentinel error is a single, package-level error value that callers can compare against:

var (
    ErrNotFound      = errors.New("not found")
    ErrUnauthorized  = errors.New("unauthorized")
    ErrAlreadyExists = errors.New("already exists")
)

Use them when there are a small number of well-known failure cases that callers will branch on. Combined with %w and errors.Is, they’re a clean way to do typed error handling.

When to use panic

Go has a panic mechanism that does unwind the stack like an exception. But it’s not for normal error handling. Don’t panic for things you can foresee — file not found, network down, bad input. Those are errors.

Reserve panic for truly impossible situations — broken assumptions, programmer mistakes that should crash development immediately. The standard library uses panic sparingly, and you should too.

A practical example

A small program that reads a list of strings, parses them as numbers, and reports errors per line:

package main

import (
    "fmt"
    "strconv"
)

func main() {
    inputs := []string{"42", "100", "twenty", "7", "n/a"}

    for i, s := range inputs {
        n, err := strconv.Atoi(s)
        if err != nil {
            fmt.Printf("line %d: skipping %q: %v\n", i+1, s, err)
            continue
        }
        fmt.Printf("line %d: parsed %d\n", i+1, n)
    }
}
line 1: parsed 42
line 2: parsed 100
line 3: skipping "twenty": strconv.Atoi: parsing "twenty": invalid syntax
line 4: parsed 7
line 5: skipping "n/a": strconv.Atoi: parsing "n/a": invalid syntax

The program survived bad input gracefully — it logged the failure and kept going. That’s the whole point of explicit errors.

What’s next

You can now handle failure. The last piece of “production-ready Go” is organizing code across files and projects — that’s packages and modules, coming up next.

Toggle theme (T)