Skip to content

Error Values

Go’s error type is the simplest interface in the standard library:

type error interface {
Error() string
}

Any type that implements a single method — Error() string — satisfies this interface and can be used wherever an error is expected. The nil value is a valid error and universally means “no error occurred.” There is nothing special about the error type at the language level; it is an ordinary interface that happens to be predeclared.

The idiomatic Go convention is to return (T, error) as the last two return values from any function that can fail. The caller always receives both and must inspect the error before trusting the result:

result, err := riskyOperation()
if err != nil {
// do not use result — it is meaningless
return fmt.Errorf("riskyOperation: %w", err)
}
// err is nil: result is valid

%w keeps the chain intact so the caller can still inspect the original error with errors.Is/errors.As — covered in the next lesson.

Returning the error to the caller is the norm. Swallowing errors silently — ignoring err with _ — should be deliberate and rare, reserved for cases where failure is genuinely irrelevant (e.g., closing a read-only file after all reads succeeded).

Go provides two standard ways to create a new error value:

errors.New creates an error from a static string. Use it when the message never changes:

var errTimeout = errors.New("operation timed out")

fmt.Errorf creates a formatted error message. Use it when you need to include runtime values like names, keys, or counts:

err := fmt.Errorf("user %d not found in database %q", userID, dbName)

Note: %v formats the value inline and does not attach wrapping metadata. Wrapping — using %w to chain errors so errors.Is and errors.As can unwrap the chain — is covered in the next lesson.

A sentinel error is a package-level variable that represents a specific, well-known failure condition:

var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")

Declaring sentinels at package scope lets callers test for them by identity. Use errors.Is rather than == so the check still works when the error has been wrapped with additional context:

if errors.Is(err, ErrNotFound) {
// handle the not-found case
}

Sentinels work best for conditions that callers need to branch on. Avoid creating a sentinel for every possible failure — reserve them for errors that callers genuinely handle differently from the generic failure case.

The explicit if err != nil check may feel repetitive at first. It is intentional. Go treats every failure as a decision point: you must choose to propagate the error, wrap it with context, retry, fall back to a default, or log and continue. The language does not make that choice for you.

This discipline produces code where every error-producing call is visible at the callsite. When you read a Go function, you can see exactly which operations can fail and what the caller does about each one — no implicit propagation, no invisible exception paths.

An error value is an interface: a (type, value) pair. It is nil only when both the type and the value are unset. This becomes a trap the moment a function declares a typed pointer variable and returns it directly instead of a bare nil:

type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func mightFail(fail bool) error {
var e *MyError // nil *MyError
if fail {
e = &MyError{msg: "boom"}
}
return e // BUG: always a non-nil error interface, even when e is nil
}

mightFail(false) returns an error whose type is *MyError and whose value is nil — that is a non-nil interface holding a nil pointer, not a nil interface. err == nil is false:

flowchart LR
  subgraph A["nil interface -- err == nil is true"]
    A1["type = none"]
    A2["value = none"]
  end
  subgraph B["typed nil in interface -- err == nil is false"]
    B1["type = *MyError"]
    B2["value = nil"]
  end
  subgraph C["real error -- err == nil is false"]
    C1["type = *MyError"]
    C2["value = pointer"]
  end
An error is a (type, value) pair -- it is nil only when both halves are unset
package main
import "fmt"
// MyError is a normal custom error type.
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
// mightFail always returns the *MyError interface type — even on the
// success path, where the underlying pointer is nil.
func mightFail(fail bool) error {
var e *MyError // nil *MyError
if fail {
e = &MyError{msg: "boom"}
}
return e // BUG: always a non-nil error interface
}
func main() {
err := mightFail(false)
fmt.Println(err == nil) // false — surprising!
fmt.Printf("%v, %T\n", err, err)
// The fix: return a bare nil, not a nil pointer of a concrete type.
var clean error
if false {
clean = &MyError{msg: "boom"}
}
fmt.Println(clean == nil) // true
}

The fix is to only ever hold a *MyError locally and return a bare error-typed nil on the success path — as clean does above. This is the single most common source of “why did if err != nil fire when nothing went wrong?” bugs in Go code, hand-written or AI-generated.

:= declares a new variable whenever at least one name on the left is new — including inside an if statement’s init clause. This is a frequent source of silently-lost errors:

func run() error {
var err error
if v, err := step(); err != nil { // BUG: := creates a new inner err
fmt.Println("inner saw:", v, err)
}
return err // always nil here, even though step() failed
}

Running this prints inner saw: 0 step failed followed by run() returned: <nil> — the outer err was never touched, because if v, err := step(); ... shadowed it with a block-scoped variable. go vet does not catch this particular shadowing (the shadow analyzer is a separate, non-default tool); reviewing every := inside an if/for next to an existing err in scope is a manual habit worth keeping.

The related discipline is to handle every error exactly once. Pick one of: return it (possibly wrapped), log it, or recover from it — never more than one, and never zero. Logging and returning the same error produces duplicate log lines further up the call stack; doing neither silently drops a real failure.

errors.Is is the general-purpose tool for sentinel comparisons because it survives wrapping. io.EOF is a deliberate, well-documented exception: the io.Reader contract guarantees io.EOF is returned as-is, never wrapped, so long-standing code still compares it directly:

n, err := r.Read(buf)
if err == io.EOF {
// end of stream, not a failure
}

errors.Is(err, io.EOF) also works and is never wrong — prefer it in new code for consistency with every other sentinel check — but do not be surprised to see == io.EOF in stdlib-adjacent code; it is not a bug.

A behaviour interface (also called an opaque error check) lets a caller ask “can this error do X?” without knowing its concrete type — useful when many unrelated error types across packages can all answer the same question:

// timeout is a behaviour interface: any error that can answer "was this a
// timeout?" satisfies it, regardless of its concrete type or package.
type timeout interface {
Timeout() bool
}
func isTimeout(err error) bool {
var t timeout
return errors.As(err, &t) && t.Timeout()
}

errors.As walks the chain looking for anything that implements the timeout interface — not a specific type. This is how net.Error’s Timeout() bool method is meant to be consumed: many different error types across the standard library and third-party code implement it, and callers check the behaviour, not the type.

package main
import (
"errors"
"fmt"
)
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
func lookup(key string) (int, error) {
switch key {
case "age":
return 30, nil
case "secret":
return 0, ErrPermission
default:
return 0, fmt.Errorf("lookup %q: %w", key, ErrNotFound)
}
}
func main() {
v, err := lookup("age")
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("age:", v)
_, err = lookup("x")
if err != nil {
fmt.Println("error:", err)
fmt.Println("is ErrNotFound:", errors.Is(err, ErrNotFound))
}
_, err = lookup("secret")
if err != nil {
fmt.Println("error:", err)
fmt.Println("is ErrPermission:", errors.Is(err, ErrPermission))
}
}

When a generated snippet returns or checks an error, look for these before trusting it:

  • Does a function that declares a typed pointer variable (var e *MyError) ever return e directly on the success path, instead of a bare nil? That’s the typed-nil trap above.
  • Does a sentinel comparison use == when the error could have passed through fmt.Errorf("...: %w", err) anywhere upstream? == breaks the moment a wrapper is introduced; errors.Is does not.
  • Is err re-declared with := inside an if/for that already has an err in scope? Check whether the outer err is actually being read afterward.
  • Is every error handled exactly once? Watch for a log.Println(err) immediately followed by return err in the same function — that logs the same failure twice as it propagates up the stack.
  • Does a behaviour check use a plain type assertion (err.(timeout)) instead of errors.As(err, &t)? A type assertion misses the interface if the error was wrapped even once.

🐛 Spot the bug

var ErrNotFound = errors.New("not found")
func find(id string) error {
return fmt.Errorf("find %s: %w", id, ErrNotFound)
}
func main() {
err := find("42")
if err == ErrNotFound {
fmt.Println("not found")
}
}
Show the answer

find wraps ErrNotFound with %w, so err is a *fmt.wrapError, not ErrNotFound itself. err == ErrNotFound compares that wrapper against the sentinel by identity and is false — the if body never runs, even though ErrNotFound is right there in the chain. Verified on 1.26.5: printing err == ErrNotFound gives false, errors.Is(err, ErrNotFound) gives true. The fix is errors.Is(err, ErrNotFound), which walks the chain instead of comparing the outermost value.

What you getBenefitCost
error interface (1 method)easy to implement, composableno built-in stack trace
errors.Is()matches a sentinel at any depth in the chainthe error must be wrapped correctly with %w first
errors.As()extracts the concrete error type along with its fieldsthe target must be a pointer to the error’s type (&ve when ve *VErr) — otherwise it panics at runtime
sentinel errorseasy to compare, self-documentingbecome part of the public API — hard to rename later
  • returning a nil error always means the function succeeded — if the caller discards the error with _ by mistake, a real error is silently ignored.
  • errors.New() and fmt.Errorf() are the same thing — fmt.Errorf with %w can wrap an error chain; errors.New cannot.
  • error messages should be capitalized — the Go convention is the opposite: error messages are lowercase with no trailing punctuation, e.g. "not found", not "Not found."
  • panic is better than returning an error for critical failures — use panic only for programmer errors that should never happen, not for expected failures.

💡 Real-world example

net/http: http.ErrServerClosed is a sentinel error for graceful shutdown — callers check it with errors.Is(err, http.ErrServerClosed).

database/sql: sql.ErrNoRows is a sentinel shared by every Go database driver.

What does returning nil as the error value mean?
What does this print?
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }

func mightFail(fail bool) error {
    var e *MyError
    if fail {
        e = &MyError{msg: "boom"}
    }
    return e
}

func main() {
    err := mightFail(false)
    fmt.Println(err == nil)
}
Assuming step() returns a non-nil error, what does run() return?
func run() error {
    var err error
    if v, err := step(); err != nil {
        fmt.Println("inner saw:", v, err)
    }
    return err
}
A sentinel error is declared with:
When should you use fmt.Errorf instead of errors.New?