Module 2 · Lesson 1 · ~25 min read
Go has no exceptions. Functions that can fail return an error as their last value, callers check it explicitly, and the rest of the language flows from that single decision.
f, err := os.Open("config.yaml")
if err != nil {
return nil, err // or fmt.Errorf("config: %w", err) — see wrapping below
}
defer f.Close()
Every line of that pattern matters. Multiple returns. err is the conventional name. Check immediately. Defer cleanup. You will write this exact shape thousands of times in real Go.
The error type is just a one-method interface:
type error interface {
Error() string
}
Anything with that method satisfies it. No exception class hierarchies, no try/catch.
Two reasons that hold up well in practice:
Trade-off: it's verbose. Real Go has a lot of if err != nil { return ... }. You learn to skim past it the same way Java reviewers skim past braces.
import "errors"
import "fmt"
errors.New("connection refused") // plain error with a message
fmt.Errorf("connect to %s: %v", host, err) // formatted
fmt.Errorf("connect to %s: %w", host, err) // formatted AND wrapped (note %w)
%w is the wrap verb. It packs the inner error inside the new one so callers can later unpack it with errors.Is / errors.As. Use it whenever you're adding context to an underlying error.
Use %v when you want the message but the inner error shouldn't be inspectable — e.g. to deliberately hide implementation details at an API boundary.
A sentinel is a package-level error variable that callers can compare against:
// In package storage
var ErrNotFound = errors.New("storage: contract not found")
func (s *Store) Get(id string) (*Contract, error) {
// ...
return nil, ErrNotFound
}
// In a caller
c, err := store.Get(id)
if errors.Is(err, storage.ErrNotFound) {
// 404 path
}
errors.Is walks the wrap chain, so even if a downstream wrapper wrapped ErrNotFound with extra context using %w, you'll still find it.
When the caller needs more than just identity — e.g. a status code, a parameter, a stack of fields — define a struct that satisfies error:
type SubmissionError struct {
CommandID string
Status int
Cause error
}
func (e *SubmissionError) Error() string {
return fmt.Sprintf("submission %s failed (status %d): %v", e.CommandID, e.Status, e.Cause)
}
// Implement Unwrap to participate in errors.Is/As walking
func (e *SubmissionError) Unwrap() error { return e.Cause }
Callers extract via errors.As:
var serr *SubmissionError
if errors.As(err, &serr) {
log.Printf("failed cmd %s with status %d", serr.CommandID, serr.Status)
}
errors.As walks the wrap chain looking for a value of the target type, and binds it.
errors.Is for "is this that specific error?" (sentinel comparison).
errors.As for "does the chain contain an error of that type?" (struct extraction).
Each layer adds context. The error becomes a chain of "what we were trying to do."
func handler() error {
if err := submitToCanton(); err != nil {
return fmt.Errorf("handler: %w", err)
}
return nil
}
func submitToCanton() error {
if err := connect(); err != nil {
return fmt.Errorf("submit: %w", err)
}
// ...
}
// At the top, error.Error() reads:
// "handler: submit: connect: dial tcp 127.0.0.1:5011: connection refused"
You get a free traceback, structurally inspectable, and human-readable. No stack required.
Discard errors with _. If you genuinely don't care, write a comment explaining why — usually you do care. vet won't catch this; humans must.
Swallow then re-construct. If you do return errors.New(err.Error()) instead of wrapping, you've turned a typed, inspectable error into a string. Every caller now has to parse strings to know what happened.
Log AND return. Logging at every layer of the call stack produces five copies of the same error in your logs. Log at the topmost handler that actually decides what to do; return at every layer below.
Go does have panic/recover. They are not exceptions. Panics signal "the program is in an impossible state — abort." Reach for panic in two cases:
Almost everything else is an error return. recover is mostly used at goroutine boundaries (net/http recovers per request, gRPC servers similarly) so a single bad request doesn't take down the process.
func (c *Client) SubmitAndWait(ctx context.Context, cmd Command) (*Completion, error) {
payload, err := c.marshal(cmd)
if err != nil {
return nil, fmt.Errorf("submit %s: marshal: %w", cmd.ID, err)
}
resp, err := c.grpcCall(ctx, payload)
if err != nil {
return nil, fmt.Errorf("submit %s: rpc: %w", cmd.ID, err)
}
return resp, nil
}
Layered context, sentinel-friendly via %w. The caller knows the command ID, knows whether it was a marshal error or an RPC error, and can still errors.Is against gRPC-specific sentinels deeper in the chain.
error is a one-method interface; errors are returned values, not thrown.fmt.Errorf("...: %w", err) wraps — preserves the chain.errors.Is for sentinels, errors.As for typed struct extraction.Error() and (when chaining) Unwrap().