Skip to content

Errors: the compiler errors

Level: 201 · anyone reading a refusal from go build that mentions err

One line: Seven refusals the compiler gives around error values — an err that is received and never read, a function that forgets its success return, an interface assigned to a concrete type, the pkg/errors names, a string where an error is wanted, errors.New with format arguments, and an Error method with the wrong signature — each recorded in the Go 1.25 compiler's own words. %w in the wrong fmt function compiles; that one is go vet's, in Errors: what go vet and gofmt catch.

Each transcript is a bash driver that writes the broken program, runs go build on it and prints the output and the exit status. Positions like ./unused_err.go:6:10: are stable because the program is fixed; the wording is this compiler's, and a later release may change it — which is why it is recorded rather than typed.

declared and not used: err

Opening a file, receiving the error, and going straight on to use the file.

Verified output of errors_err_declared_not_used_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build unused_err.go
# command-line-arguments
./unused_err.go:6:10: declared and not used: err
exit status 1

The mistake. err was assigned and never read. Go has no warning level for this: an unused local variable is a compile error, and an unused error result is the case the rule exists for.

The fix. Check it, on the next line, before touching what came with it:

orders, err := os.Open("orders.csv")
if err != nil {
    return fmt.Errorf("open orders: %w", err)
}
defer orders.Close()

missing return

The failure path returns, the success path prints and falls off the end.

Verified output of errors_err_missing_return_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build missing_return.go
# command-line-arguments
./missing_return.go:14:1: missing return
exit status 1

The mistake. A function with results must end in a terminating statement, and an if without an else is not one, whatever its body returns (spec ↗). The compiler points at the closing brace.

The fix. Return the success value after the check — the if err != nil { return } shape leaves the happy path at the left margin on purpose:

if err != nil {
    return 0, fmt.Errorf("size of %s: %w", name, err)
}
return info.Size(), nil

cannot use err (variable of interface type error) as *NotFoundError value

Assigning an error to a variable of the concrete type it is expected to hold.

Verified output of errors_err_need_type_assertion_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build need_assertion.go
# command-line-arguments
./need_assertion.go:13:26: cannot use err (variable of interface type error) as *NotFoundError value in variable declaration: need type assertion
exit status 1

The mistake. An interface value does not convert down by itself; the compiler says what it wants — a type assertion. And the assertion only looks at the outer layer, so if err was ever wrapped it will fail at run time instead.

The fix. errors.As is the assertion that also searches the chain — errors.As finds a type in the chain:

var nf *NotFoundError
if errors.As(err, &nf) {
    fmt.Println(nf.ID)
}

undefined: errors.Wrap

Writing errors.Wrap and errors.Cause from memory of the github.com/pkg/errors package, with the standard errors imported.

Verified output of errors_err_undefined_wrap_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build pkg_errors_habit.go
# command-line-arguments
./pkg_errors_habit.go:12:22: undefined: errors.Wrap
./pkg_errors_habit.go:13:22: undefined: errors.Cause
exit status 1

The mistake. The standard package never had Wrap or Cause. Since Go 1.13 the wrap is fmt.Errorf with %w, and the walk is errors.Is, errors.As and errors.Unwrap; pkg/errors ↗ is in maintenance mode and its README says so.

The fix.

return fmt.Errorf("load orders: %w", err)   // errors.Wrap(err, "load orders")
root := err
for errors.Unwrap(root) != nil {            // errors.Cause(err)
    root = errors.Unwrap(root)
}

string does not implement error

Comparing an error with its text — once by handing the text to errors.Is, once with err.Error and no parentheses.

Verified output of errors_err_not_an_error_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build text_is_not_an_error.go
# command-line-arguments
./text_is_not_an_error.go:12:20: cannot use "order not found" (constant of type string) as error value in argument to errors.Is: string does not implement error (missing method Error)
./text_is_not_an_error.go:15:18: invalid operation: err.Error == "order not found" (mismatched types func() string and untyped string)
exit status 1

The mistake. A string is not an error: it has no Error method, so it cannot be the second argument of errors.Is. And err.Error without the call is a method value of type func() string, which is not comparable with a string either. Both are symptoms of the same idea — matching on the message — which also breaks the moment someone rewords it.

The fix. Compare with the sentinel, through the chain — %w wraps and errors.Is sees through it:

if errors.Is(err, ErrNotFound) {

too many arguments in call to errors.New

Giving errors.New a format verb and an argument, the way fmt.Errorf takes them.

Verified output of errors_err_new_too_many_args_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build new_with_args.go
# command-line-arguments
./new_with_args.go:7:42: too many arguments in call to errors.New
    have (string, int)
    want (string)
exit status 1

The mistake. errors.New takes one string and does no formatting; the have/want lines show the signature.

The fix. errors.New for a fixed message — usually a sentinel at package level — and fmt.Errorf for one built from values:

var ErrNotFound = errors.New("order not found")
err := fmt.Errorf("order %d: %w", id, ErrNotFound)

wrong type for method Error

A type whose Error method returns an error instead of a string, returned where an error is expected.

Verified output of errors_err_wrong_error_signature_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

$ go build wrong_signature.go
# command-line-arguments
./wrong_signature.go:11:10: cannot use QuotaError{…} (value of struct type QuotaError) as error value in return statement: QuotaError does not implement error (wrong type for method Error)
        have Error() error
        want Error() string
exit status 1

The mistake. The error interface is Error() string and nothing else (spec ↗); a method with the same name and a different signature does not count, and the compiler prints the signature it has and the one it wants.

The fix.

func (e QuotaError) Error() string { return fmt.Sprintf("quota of %d exceeded", e.Limit) }

The drivers behind every transcript above, in full:

The seven drivers

errors_err_declared_not_used_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# An error result that is received and never looked at. The compiler refuses:
# err is declared and not used, and there is no warning level to lower it to.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/unused_err.go" <<'GO'
package main

import "os"

func main() {
    orders, err := os.Open("orders.csv")
    orders.Close()
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build unused_err.go'
(cd "$dir" && go build unused_err.go) 2>&1
echo "exit status $?"

errors_err_missing_return_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The failure path returns and the success path forgets to. A function with
# results must end in a terminating statement, and an if without else is not one.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/missing_return.go" <<'GO'
package main

import (
    "fmt"
    "os"
)

func size(name string) (int64, error) {
    info, err := os.Stat(name)
    if err != nil {
        return 0, fmt.Errorf("size of %s: %w", name, err)
    }
    fmt.Println("size:", info.Size())
}

func main() { fmt.Println(size("orders.csv")) }
GO

say() { printf '$ %s\n' "$*"; }

say 'go build missing_return.go'
(cd "$dir" && go build missing_return.go) 2>&1
echo "exit status $?"

errors_err_need_type_assertion_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Assigning an error to a variable of the concrete type. An interface value
# does not convert down by itself: the compiler asks for a type assertion,
# and errors.As is the assertion that also searches the chain.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/need_assertion.go" <<'GO'
package main

import "fmt"

type NotFoundError struct{ ID int }

func (e *NotFoundError) Error() string { return fmt.Sprintf("order %d not found", e.ID) }

func findOrder(id int) error { return &NotFoundError{id} }

func main() {
    err := findOrder(7)
    var nf *NotFoundError = err
    fmt.Println(nf.ID)
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build need_assertion.go'
(cd "$dir" && go build need_assertion.go) 2>&1
echo "exit status $?"

errors_err_undefined_wrap_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# The pkg/errors habit. The standard errors package has no Wrap and no Cause;
# since Go 1.13 the wrap is fmt.Errorf with %w and the walk is errors.Is,
# errors.As and errors.Unwrap.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/pkg_errors_habit.go" <<'GO'
package main

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

func main() {
    _, err := os.Open("orders.csv")
    if err != nil {
        fmt.Println(errors.Wrap(err, "load orders"))
        fmt.Println(errors.Cause(err))
    }
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build pkg_errors_habit.go'
(cd "$dir" && go build pkg_errors_habit.go) 2>&1
echo "exit status $?"

errors_err_not_an_error_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# Comparing an error with its text. A string is not an error, so errors.Is
# refuses it; and err.Error without the parentheses is a method value, not
# a string, so == refuses that too.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/text_is_not_an_error.go" <<'GO'
package main

import (
    "errors"
    "fmt"
)

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

func main() {
    err := fmt.Errorf("load order 7: %w", ErrNotFound)
    if errors.Is(err, "order not found") {
        fmt.Println("not found")
    }
    if err.Error == "order not found" {
        fmt.Println("not found")
    }
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build text_is_not_an_error.go'
(cd "$dir" && go build text_is_not_an_error.go) 2>&1
echo "exit status $?"

errors_err_new_too_many_args_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# errors.New takes one string and does no formatting. A format verb and an
# argument belong to fmt.Errorf.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/new_with_args.go" <<'GO'
package main

import "errors"

func main() {
    id := 7
    err := errors.New("order %d not found", id)
    _ = err
}
GO

say() { printf '$ %s\n' "$*"; }

say 'go build new_with_args.go'
(cd "$dir" && go build new_with_args.go) 2>&1
echo "exit status $?"

errors_err_wrong_error_signature_sh.sh in full — pasted here by tools/run_examples.py from the file CI runs.

#!/usr/bin/env bash
# A type whose Error method returns an error instead of a string does not
# implement error, and the compiler says exactly which signature it wanted.
set -u
export GOTOOLCHAIN=local

dir=$(mktemp -d)
trap 'rm -rf "$dir"' EXIT

cat >"$dir/wrong_signature.go" <<'GO'
package main

import "fmt"

type QuotaError struct{ Limit int }

func (e QuotaError) Error() error { return fmt.Errorf("quota of %d exceeded", e.Limit) }

func reserve(n int) error {
    if n > 10 {
        return QuotaError{10}
    }
    return nil
}

func main() { fmt.Println(reserve(11)) }
GO

say() { printf '$ %s\n' "$*"; }

say 'go build wrong_signature.go'
(cd "$dir" && go build wrong_signature.go) 2>&1
echo "exit status $?"

What compiles anyway

A second err := inside an if block, shadowing the outer err so that the function returns nil after a failure, is legal Go and the compiler says nothing. So is %w handed to Sprintf, a typed nil returned as an error, and == on a wrapped sentinel. Those are on Errors: what go vet and gofmt catch — the first two under the analyzers that do report them, the rest under what no tool catches.

Sources