ZetCode

Go error

last modified October 30, 2026

In this article we show how to handle errors in Go. An error is an ordinary value of the built-in error type: it can be returned, stored, compared and wrapped. A nil error means that the operation succeeded.

The examples were compiled and run with Go 1.27.0. Programs that report expected errors with fmt.Println produce stable output; a real log.Fatal would add a timestamp.

Errors are values

A function that can fail returns an error as its last result. The caller checks it immediately, before using the other results.

main.go
package main

import (
    "fmt"
    "os"
)

func main() {

    data, err := os.ReadFile("thermopylae.txt")
    if err != nil {
        fmt.Println(err)
        return
    }

    fmt.Print(string(data))

    _, err = os.ReadFile("missing.txt")
    fmt.Println(err)
}

os.ReadFile returns the contents and an error. The successful call leaves the error nil, while the missing file produces an error that includes the operation and the path.

$ go run main.go
The Battle of Thermopylae was fought in 480 BC.
open missing.txt: no such file or directory

Creating errors

errors.New creates a new error with a fixed message. Two calls with the same text return two distinct values, so an error is usually compared with a package-level sentinel variable rather than with a new value.

main.go
package main

import (
    "errors"
    "fmt"
)

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

func main() {

    err := ErrNotFound
    fmt.Println(err)
    fmt.Println(err == ErrNotFound)

    a := errors.New("boom")
    b := errors.New("boom")
    fmt.Println(a == b)
    fmt.Println(a.Error() == b.Error())
}

The sentinel compares equal to itself. The two separately created errors have the same message but different identities, so only their text is equal.

$ go run main.go
record not found
true
false
true

Wrapping errors

fmt.Errorf with the %w verb creates a new error that wraps the original one. errors.Is walks the chain, while %v only copies the text and loses the link.

main.go
package main

import (
    "errors"
    "fmt"
)

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

func main() {

    wrapped := fmt.Errorf("lookup: %w", ErrNotFound)
    plain := fmt.Errorf("lookup: %v", ErrNotFound)

    fmt.Println(wrapped)
    fmt.Println("wrapped is:", errors.Is(wrapped, ErrNotFound))
    fmt.Println("plain is:  ", errors.Is(plain, ErrNotFound))
}

Both values print the same text. Only the wrapped one still answers true to errors.Is, because the original error is part of its chain.

$ go run main.go
lookup: record not found
wrapped is: true
plain is:   false

Unwrapping a chain

A sentinel can be wrapped with additional context at each level. The message grows, but the identity remains reachable.

main.go
package main

import (
    "errors"
    "fmt"
    "os"
)

func loadConfig() error {

    _, err := os.Open("config.yaml")
    return fmt.Errorf("load config: %w", err)
}

func start() error {

    return fmt.Errorf("start server: %w", loadConfig())
}

func main() {

    err := start()
    fmt.Println(err)
    fmt.Println("not exist:", errors.Is(err, os.ErrNotExist))
    fmt.Println("unwrapped:", errors.Unwrap(err))
}

errors.Is(err, os.ErrNotExist) succeeds even though the fs.PathError from os.Open is two wrappers deep. errors.Unwrap removes only the outermost layer.

$ go run main.go
start server: load config: open config.yaml: no such file or directory
not exist: true
unwrapped: load config: open config.yaml: no such file or directory

Custom error types

A type implements the error interface by providing an Error method. A pointer receiver is the usual choice because it avoids copying the struct, and errors.As still recovers the concrete value together with its fields.

main.go
package main

import (
    "errors"
    "fmt"
)

type ValidationError struct {
    Field string
    Value string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("invalid %s: %q", e.Field, e.Value)
}

func createUser(name string) error {

    if name == "" {
        return &ValidationError{Field: "name", Value: name}
    }
    return nil
}

func main() {

    err := fmt.Errorf("create user: %w", createUser(""))
    fmt.Println(err)

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

The error is wrapped with context before it is inspected. errors.As takes a pointer to a variable of the target type and walks the chain until it finds a matching value.

$ go run main.go
create user: invalid name: ""
field: name
value: 

Joining errors

errors.Join combines several errors into one. The result prints each error on its own line and satisfies errors.Is for every member of the group.

main.go
package main

import (
    "errors"
    "fmt"
)

func main() {

    dbErr := errors.New("database unavailable")
    cacheErr := fmt.Errorf("cache: %w", errors.New("timeout"))

    err := errors.Join(dbErr, cacheErr)

    fmt.Println(err)
    fmt.Println("db:   ", errors.Is(err, dbErr))
    fmt.Println("cache:", errors.Is(err, errors.Unwrap(cacheErr)))
}

Joining is useful when an operation has several independent failures, for example when shutting down several components and collecting every problem instead of returning after the first one.

$ go run main.go
database unavailable
cache: timeout
db:    true
cache: true

Panics and recover

A panic unwinds the stack and terminates the program unless a deferred function calls recover. A recovered panic can be turned into an ordinary error, but it should not be used for expected failures.

main.go
package main

import (
    "fmt"
)

func divide(a, b int) (result int, err error) {

    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
        }
    }()

    return a / b, nil
}

func main() {

    res, err := divide(10, 2)
    fmt.Println(res, err)

    res, err = divide(10, 0)
    fmt.Println(res, err)
    fmt.Printf("%T\n", err)
}

The division by zero raises a runtime panic. The deferred function recovers it, converts it to an error with fmt.Errorf and lets divide return normally. The result is zero because the assignment never happened.

$ go run main.go
5 <nil>
0 recovered: runtime error: integer divide by zero
*errors.errorString

Source

Go errors package - reference
Error handling and Go

In this article we have worked with errors in Go. We checked errors as values, created sentinel errors, wrapped them with %w, inspected chains with errors.Is and errors.Unwrap, recovered typed information with errors.As, combined failures with errors.Join and converted a panic into an error with recover.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.