Go

How do you parse CLI flags with the flag package? How do subcommands work, and why is flag.Parse in init() a bug?

Question 544MediumGo 1.22 to 1.25

You define flags (flag.Int, flag.StringVar, flag.Func, flag.TextVar, flag.BoolFunc), call flag.Parse() once in main, and read flag.Args() for the positional arguments. For subcommands, create a separate flag.NewFlagSet for each one and parse os.Args[2:]:

func main() {
	if len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: app <serve|migrate> [flags]")
		os.Exit(2)
	}
	switch os.Args[1] {
	case "serve":
		fs := flag.NewFlagSet("serve", flag.ExitOnError)
		port := fs.Int("port", 8080, "listen port")
		debug := fs.Bool("debug", false, "verbose logging")
		fs.Parse(os.Args[2:])
		fmt.Println("serve", *port, *debug, fs.Args())
	case "migrate":
		fs := flag.NewFlagSet("migrate", flag.ExitOnError)
		steps := fs.Int("steps", 0, "number of steps (0 = all)")
		fs.Parse(os.Args[2:])
		fmt.Println("migrate", *steps)
	default:
		fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
		os.Exit(2)
	}
}

Why flag.Parse() in init() is a bug: other packages, including testing, register their flags during their own initialization. Since Go 1.13, test flags are registered by testing.Init(), which the generated test main calls after all package inits have run. Parsing early therefore fails with flag provided but not defined: -test.v, or it misses flags defined later. It also makes the package impossible to import cleanly.

Gotchas: parsing stops at the first non-flag argument, so in app serve file.txt -port 9 the -port is not parsed. Bool flags need -debug=false, because -debug false sets debug to true and leaves "false" as a positional argument. Use ContinueOnError in libraries and tests.

More on More Standard Library Essentials

All 16 More Standard Library Essentials questions