Skip to main content

Command-Line Parsing and Flags in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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 -verbose or -config)
  • You want shell completion generation
  • Your CLI resembles kubectl, docker, or git in 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.Usage for real tools — describe the program, list examples
  • Validate flag values after flag.Parse() — the package only handles type coercion, not business rules
  • Implement flag.Value for custom types (repeatable flags, enums, validated strings)
  • flag.FlagSet enables 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

Comments

👍 Was this article helpful?