Error Handling
Scope: Go's errors-as-values model: the error type, the
if err != nil pattern, wrapping, and custom errors. No
exceptions exist — this is the whole system. All examples were
compiled and run.
The core idea
In Go, errors are values, not exceptions. A function that can
fail returns an error as its last return value. The caller
checks it. There's no throwing, no catching, no invisible control
flow — failure handling is right there in the code, every time.
The pattern you'll write a thousand times
f, err := os.Open("data.txt")
if err != nil {
log.Fatal(err)
}
defer f.Close()
That's it. Call, check err, handle it. It looks repetitive at
first; after a week it becomes invisible, and you'll miss it in
languages that hide failures.
What error actually is
type error interface {
Error() string
}
error is a built-in interface with one method. Anything with an
Error() string method is an error. Creating one:
import "errors"
err := errors.New("something went wrong")
Or with formatting:
import "fmt"
err := fmt.Errorf("user %s not found", name)
Wrapping: adding context as errors travel up
func loadConfig(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("loadConfig %s: %w", path, err)
}
return data, nil
}
%w (not %v) wraps the original error inside the new one.
Wrapping preserves the chain: callers can see your message
("loadConfig config.yaml") and still unwrap down to the root
cause ("file not found"). Always use %w when adding context.
Checking the chain:
if errors.Is(err, os.ErrNotExist) {
fmt.Println("file's missing — using defaults")
}
errors.Is looks through the whole wrap chain for a match.
Custom error types
When callers need to distinguish kinds of failure programmatically, define a type:
type NotFoundError struct {
Resource string
}
func (e *NotFoundError) Error() string {
return "not found: " + e.Resource
}
Callers then check with errors.As:
var nf *NotFoundError
if errors.As(err, &nf) {
fmt.Println("missing:", nf.Resource)
}
Most programs never need this. Start with fmt.Errorf and
errors.Is; reach for custom types when callers must branch on
the error kind.
panic: the (rare) escape hatch
panic("unreachable")
panic crashes the program. Legitimate uses: impossible
situations ("this can't happen"), and quick prototypes. Go's
panic is like Rust's — for bugs, not for expected failures
like bad input or missing files. Those get returned as error.
Libraries should essentially never panic on external input.
The decision table
| Situation | Do this |
|---|---|
| Function can fail | Return error as the last value |
| Checking a call | if err != nil immediately |
| Adding context | fmt.Errorf("...: %w", err) |
| Caller must distinguish kinds | Custom error type + errors.As |
| Impossible situation / prototype | panic |
Check
- [ ] You can write the
if err != nilpattern from memory - [ ] You know the difference between
%wand%vinErrorf - [ ] You can say when
panicis appropriate and when it isn't
Next: Troubleshooting.