Lesson 10 · Errors & idiomatic Go

Errors are values

No exceptions. You return an error, add context, and check it.

Your win: explain Go's "errors as values" model, wrap an error with %w to add context without losing the original, define a sentinel — and spot the %v bug hiding in our own handlers.

An error is just a value implementing one method

error is an ordinary interface: interface { Error() string }. There are no exceptions in Go — a function that can fail returns an error as its last result, and the caller checks it. Control flow stays visible.1

resp, err := svc.CreateEmail(ctx, payload)
if err != nil {
    return nil, err   // explicit — no hidden unwind
}

Create errors with errors.New("msg") or fmt.Errorf(...). A sentinel error is a package-level value you can compare against later:

internal/spike/modules/email/controller/grpc/validator.go:22-33
var (
    ErrEmpty           = errors.New("empty")
    ErrMediasSizeExceed = errors.New("medias size exceed")
    ErrInternal        = errors.New("internal")
)

Wrapping: add context, keep the original

As an error bubbles up, each layer adds context with fmt.Errorf and the %w verb. %w wraps — it embeds the original error so it can be recovered later (Lesson 11), while prepending a breadcrumb.2

internal/spike/modules/email/infrastructure/repositories/email.go:29,109
return fmt.Errorf("multierr.Combine: %w", err)
return fmt.Errorf("repo.queueUpsert: %w", err)
// A reader sees: "repo.queueUpsert: multierr.Combine: <root cause>"
🐛 A real bug in our repo — learn from it Some handlers wrap with %v instead of %w (create_email_handler.go:34,45). %v formats the error to text and drops the reference — so the returned error does not wrap the original. Any downstream errors.Is / errors.As (Lesson 11) is then blind to the root cause. It's a subtle, common mistake — and a perfect interview "what's wrong with this line?"
Anchor Our repos also aggregate multiple failures with go.uber.org/multierr (multierr.Combine/Append, repositories/email.go) and some layers use github.com/pkg/errors's errors.Wrap. The rule of thumb documented in the repo: wrap at each call site with fmt.Errorf("call.Site: %w", err) so the final message reads like a stack.

Why Go chose this

Errors-as-values makes failure a normal, visible part of the signature — you can't forget an error is possible, and there's no invisible throw three frames down. The cost is verbosity (if err != nil everywhere); the benefit is that reading a function tells you exactly how it can fail. Interviewers want the trade, not just the complaint.

Read this next

The Go Blog — "Working with Errors in Go 1.13"

The canonical explanation of %w wrapping (and Is/As, which we cover next). Pair with Effective Go's errors section.

go.dev/blog/go1.13-errors
Effective Go — errors

Check yourself (from memory)

Q1. In Go, an error is…

error is a plain interface value returned and checked — no exceptions, explicit control flow.

Q2. fmt.Errorf("load: %w", err) differs from %v because it…

%w keeps the original reachable for errors.Is/As; %v flattens it to a string and breaks the chain.

Q3. A sentinel error is…

var ErrX = errors.New(...) — a named value you compare against with errors.Is. Our validator has several.
Why does fmt.Errorf("x: %v", err) quietly break downstream error handling?
recall, then click to reveal
%v renders the error to text and drops the reference, so the returned error does not wrap the original. Downstream errors.Is(err, ErrTarget) / errors.As can't unwrap to find it — the chain is severed. Use %w to keep the original reachable. (This is a real bug in create_email_handler.go.)
Want the guidance on when to wrap vs. handle-and-stop, or add fields to a custom error type? Ask me — it leads straight into Lesson 11.

1. Effective Go — errors; The Go Blog — Errors are values.

2. The Go Blog — Working with Errors in Go 1.13.