Error handling in main()
When writing applications in Go, the fact that main()
does not return an error can sometimes lead to a strange dichotomy between error handling in main()
and error handling in the rest of the program. As you write program logic in main()
, you’ll often need to print the error and explicitly exit with a non-zero code, as opposed to the more standard return fmt.Errorf("...")
method of error handling.
To deal with this, I often put the main behavior of the program in a separate func run() error
, leaving error handling from run()
as main()
’s sole job.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main
import (
"fmt"
"os"
)
func main() {
if err := run(); err != nil {
if err.Error() != "" {
fmt.Fprintln(os.Stderr, err)
}
os.Exit(1)
}
}
func run() error {
info, err := os.Stat("/etc/passwd")
if err != nil {
return fmt.Errorf("could not stat /etc/passwd: %w", err)
}
fmt.Println("size:", info.Size())
return nil
}
This makes the error handling consistent between run()
and the rest of the program, so that moving code between run()
and other functions is simpler to do. It also makes run()
more testable: you can look for sentinel errors from the function rather than trying to capture logged output from main()
.
The check to see if the returned error has a non-zero value for its string can be useful for situations where program output is handled by run()
(such as responding to a --help
flag) but where you still want to signal that the program should stop with a non-zero exit code.
If you want to exit with different exit codes based on specific error types, then you can check for those in main()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main
import (
"errors"
"fmt"
"os"
)
var errUsage = errors.New("usage: mycli [file]")
func main() {
if err := run(); err != nil {
if err.Error() != "" {
fmt.Fprintln(os.Stderr, err)
}
switch {
case errors.Is(err, errUsage):
os.Exit(2)
default:
os.Exit(1)
}
}
}
func run() error {
if len(os.Args) < 2 {
return errUsage
}
info, err := os.Stat(os.Args[1])
if err != nil {
return fmt.Errorf("could not stat %q: %w", os.Args[1], err)
}
fmt.Println("size:", info.Size())
return nil
}
This allows run()
to act like any other Go function and leaves the details of which errors correspond to which exit codes inside main()
.
It also frees you up to register defers scoped to the entire program within run()
without the possibility of those defers being skipped by an os.Exit()
.