Go’s standard library flag package covers most CLI flag needs without any dependencies. It handles parsing, default values, help text generation, and type coercion. For simple tools and scripts, it’s the right choice. When your CLI grows to multiple subcommands with shared flags and persistent configuration, that’s when Cobra (covered in Go building CLI with Cobra) makes sense.
This guide focuses on the flag package patterns and the design questions that come up regardless of which library you use.
Basic Flag Definitions
The flag package registers flags and parses os.Args[1:] on flag.Parse(). Flags are defined before Parse is called — typically in init() or at the start of main():
package main
import (
"flag"
"fmt"
"log"
"os"
)
func main() {
// Define flags with: flag.Type(name, default, usage)
host := flag.String("host", "localhost", "server hostname")
port := flag.Int("port", 8080, "server port")
verbose := flag.Bool("verbose", false, "enable verbose output")
timeout := flag.Duration("timeout", 30*time.Second, "request timeout")
flag.Parse()
// Remaining non-flag arguments
args := flag.Args()
if *verbose {
fmt.Printf("connecting to %s:%d (timeout %s)\n", *host, *port, *timeout)
}
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "error: at least one argument required")
flag.Usage()
os.Exit(1)
}
}
flag.String, flag.Int, etc. return pointers — you dereference with *host, *port. The Var variants bind to an existing variable directly, which is cleaner when flags populate a config struct:
type Config struct {
Host string
Port int
Verbose bool
}
var cfg Config
func init() {
flag.StringVar(&cfg.Host, "host", "localhost", "server hostname")
flag.IntVar(&cfg.Port, "port", 8080, "server port")
flag.BoolVar(&cfg.Verbose, "verbose", false, "enable verbose output")
}
Customizing Usage Output
The default flag.Usage output is functional but terse. Override it for better first-run experience:
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <file> [files...]\n\n", os.Args[0])
fmt.Fprintln(os.Stderr, "Flags:")
flag.PrintDefaults()
fmt.Fprintln(os.Stderr, "\nExamples:")
fmt.Fprintf(os.Stderr, " %s -port 9090 config.yaml\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s -verbose -host api.example.com data.json\n", os.Args[0])
}
// ... define flags ...
flag.Parse()
}
flag.PrintDefaults() prints all defined flags with their defaults and usage strings. Wrap it with your program description and examples.
Validation After Parsing
The flag package only parses types — it doesn’t validate values. Add validation immediately after flag.Parse():
func validateFlags(cfg Config) error {
if cfg.Host == "" {
return fmt.Errorf("host cannot be empty")
}
if cfg.Port < 1 || cfg.Port > 65535 {
return fmt.Errorf("port must be 1–65535, got %d", cfg.Port)
}
validFormats := map[string]bool{"json": true, "yaml": true, "text": true}
if !validFormats[cfg.Format] {
return fmt.Errorf("format must be json, yaml, or text; got %q", cfg.Format)
}
return nil
}
func main() {
// ... flag definitions ...
flag.Parse()
if err := validateFlags(cfg); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
flag.Usage()
os.Exit(1)
}
}
Keeping validation separate from flag definition keeps each function focused and makes the validation logic testable independently.
Custom Flag Types
When the built-in types (string, int, bool, duration, float64) aren’t enough, implement flag.Value:
// StringSlice accepts -tag value multiple times, building a slice
type StringSlice []string
func (s *StringSlice) String() string { return strings.Join(*s, ",") }
func (s *StringSlice) Set(v string) error {
*s = append(*s, v)
return nil
}
// LogLevel accepts -level debug|info|warn|error
type LogLevel struct{ level slog.Level }
func (l *LogLevel) String() string { return l.level.String() }
func (l *LogLevel) Set(v string) error {
return l.level.UnmarshalText([]byte(v))
}
// Usage
var tags StringSlice
var level LogLevel
flag.Var(&tags, "tag", "tag to include (can be specified multiple times)")
flag.Var(&level, "level", "log level: debug, info, warn, error")
flag.Parse()
// -tag foo -tag bar → tags = ["foo", "bar"]
// -level warn → level.level = slog.LevelWarn
The String() method controls how the default is displayed in --help. The Set() method is called once per flag occurrence.
Subcommands with flag.FlagSet
flag.FlagSet creates an independent set of flags — the foundation for subcommands. Each subcommand gets its own FlagSet with its own flags:
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: myapp <command> [flags]")
fmt.Fprintln(os.Stderr, "commands: serve, deploy, version")
os.Exit(1)
}
switch os.Args[1] {
case "serve":
runServe(os.Args[2:])
case "deploy":
runDeploy(os.Args[2:])
case "version":
fmt.Printf("myapp v%s\n", version)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
func runServe(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", 8080, "port to listen on")
host := fs.String("host", "", "host to bind to")
fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: myapp serve [flags]")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
// validate and run
fmt.Printf("serving on %s:%d\n", *host, *port)
}
func runDeploy(args []string) {
fs := flag.NewFlagSet("deploy", flag.ExitOnError)
env := fs.String("env", "staging", "target environment: staging, production")
force := fs.Bool("force", false, "skip confirmation prompt")
if err := fs.Parse(args); err != nil {
os.Exit(1)
}
// ...
}
flag.ExitOnError makes the FlagSet call os.Exit(2) on parse failure. flag.ContinueOnError returns an error instead, which is better for testing.
Environment Variable Fallback
A common pattern: flags override environment variables, which override built-in defaults. This lets users set persistent configuration in the environment and override per-run with flags:
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func envOrInt(key string, fallback int) int {
if v := os.Getenv(key); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// Use env vars as defaults — flags still override
host := flag.String("host", envOr("APP_HOST", "localhost"), "host (env: APP_HOST)")
port := flag.Int("port", envOrInt("APP_PORT", 8080), "port (env: APP_PORT)")
Document in the usage string which environment variable each flag reads from. Users who set APP_HOST=api.example.com in their environment don’t need to pass -host every time.
When to Use flag vs Cobra
The flag package is the right choice when:
- Your tool has a single command (or very few)
- You don’t need persistent flags shared across subcommands
- You want zero dependencies
- The tool is a script-like utility run by developers
Cobra is the right choice when:
- You have many subcommands with their own flags
- You need persistent flags that apply to all subcommands (like
-verboseor-config) - You want shell completion generation
- Your CLI resembles
kubectl,docker, orgitin structure
The flag package’s subcommand pattern using FlagSet covers moderate complexity. For tools with 10+ subcommands and global flags, Cobra’s structure is worth the dependency.
Summary
flag.StringVar,flag.IntVar, etc. bind flags directly to struct fields — cleaner than the pointer form- Always override
flag.Usagefor real tools — describe the program, list examples - Validate flag values after
flag.Parse()— the package only handles type coercion, not business rules - Implement
flag.Valuefor custom types (repeatable flags, enums, validated strings) flag.FlagSetenables subcommands — one FlagSet per subcommand, each with its own flags- Use environment variables as defaults with a fallback pattern — document which env var each flag reads
Resources
- flag package documentation
- Go by Example: Command-Line Flags
- Command Line Interface Guidelines (clig.dev)
Comments