What is the best way to handle errors in Go? Specifically when building a CLI tool with multiple layers of function calls — I want errors to bubble up clearly to the top-level main() without losing context.
I’ve seen patterns using fmt.Errorf with %w for wrapping, sentinel errors with errors.Is, and custom error types with errors.As. But I’m not sure when to use which approach, especially in a CLI where you might have database errors, network errors, validation errors, and user input errors all bubbling up through different layers.
Should I create a central error type? Use error codes? How do I make sure the final error message printed to stderr is actually useful to the person running the CLI, while still preserving enough detail for debugging? And how does this interact with exit codes — should different error types map to different exit codes, or is that overcomplicating things?
The canonical Go CLI pattern is a run() error function called from main(), which handles the exit code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
}
func run() error {
if err := step1(); err != nil {
return fmt.Errorf("step1: %w", err)
}
if err := step2(); err != nil {
return fmt.Errorf("step2: %w", err)
}
return nil
}
|
The %w verb wraps errors so callers can use errors.Is and errors.As. Each layer adds context with fmt.Errorf("context: %w", err) — this builds a readable chain like step2: db: connection refused without losing the original error type.
For Go CLI error handling, use sentinel errors and wrapping together:
1
2
3
4
5
6
7
8
| var ErrInvalidInput = errors.New("invalid input")
func process(input string) error {
if input == "" {
return fmt.Errorf("process: %w", ErrInvalidInput)
}
return nil
}
|
At the top level, errors.Is(err, ErrInvalidInput) lets you distinguish user errors (show friendly message) from internal errors (show stack trace or log). This is especially useful in CLIs where exit codes should reflect error type.