Skip to content
intermediate Phase 15 · CLI Tools, Logging, and Profiling

Command-Line Flags with the flag Package

Parse typed flags, subcommands, and positional arguments for CLI programs.

45m
0 problems
Topic Progress 0%

The Nightly Job That Could Not Survive a Directory Move

A backup script runs from cron at 02:00. The source directory is /var/data, written directly into the source file because it never changes. In March the storage team mounts a new volume at /srv/data, and the only fix is an edit, a rebuild, and a redeploy of a program whose logic did not change at all. Every path, limit, and toggle hardcoded into a binary is a decision that will later need to be made by someone who cannot recompile it.

The flag package turns command-line arguments into typed values with defaults, and the whole API fits in four moves: declare, parse, check, use.

package main

import (
	"flag"
	"fmt"
	"os"
	"time"
)

func main() {
	src := flag.String("src", "", "directory to archive (required)")
	dest := flag.String("dest", "backup.tar", "archive output path")
	keep := flag.Int("keep", 30, "days to retain archives")
	verbose := flag.Bool("v", false, "print each file as it is added")
	timeout := flag.Duration("timeout", 30*time.Second, "max runtime for one pass")
	flag.Parse()

	if *src == "" {
		fmt.Fprintln(os.Stderr, "error: -src is required")
		flag.Usage()
		os.Exit(2)
	}

	deadline := time.Now().Add(*timeout)
	if err := archive(*src, *dest, *keep, *verbose, deadline); err != nil {
		fmt.Fprintln(os.Stderr, "backup:", err)
		os.Exit(1)
	}
}

Each constructor registers a flag — a name, a default, a usage string — and hands back a pointer to the storage it created. flag.Parse() reads os.Args[1:] and writes the parsed results through those pointers, so *keep is an int with no strconv.Atoi anywhere in your code. The default argument does double duty: it is what -keep prints as in the usage text and what the value equals when the flag is absent. Zero values never stand in for defaults — an omitted -keep means 30, not 0.

Three behaviors of flag.Parse() are worth memorizing because scripts depend on them:

  • -h or -help prints the usage block and exits with status 0.
  • An unknown flag prints the error, then the usage block, then exits with status 2.
  • Parsing stops at the first argument that does not begin with -. Everything from there on lands in flag.Args().

That last rule produces the classic surprise: backup -src ./data report.txt -v sets src, then stops at report.txt, so verbose stays false and flag.Args() returns [report.txt -v]. The flag package does not support interspersed positionals. Put flags before operands, or document that your tool requires it.

Under the hood, flag.Parse walks a FlagSet — a registry mapping each name to a flag.Value. The typed constructors are just conveniences over that interface, and the registry explains the whole error surface: an unknown flag is a map miss, a bad value is a failing Set call, and a duplicate registration panics with flag redefined — which you will meet the first time two helpers both define verbose on the same set.

One Binary, Four Verbs: Subcommands Without a Framework

The deployment tool is growing: ship build, ship push, ship rollback. Each verb wants different flags — -tag means something different to build and to push — and a single flat flag list would drown the usage text. Third-party CLI frameworks solve this, but the standard library already does: one flag.FlagSet per verb, dispatched from os.Args[1].

func buildCmd(args []string) error {
	fs := flag.NewFlagSet("ship build", flag.ContinueOnError)
	tag := fs.String("tag", "", "image tag to build (required)")
	platform := fs.String("platform", "linux/amd64", "target platform")
	if err := fs.Parse(args); err != nil {
		return err
	}
	if *tag == "" {
		return fmt.Errorf("build: -tag is required")
	}
	return build(*tag, *platform)
}

func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(2)
	}

	var err error
	switch verb, rest := os.Args[1], os.Args[2:]; verb {
	case "build":
		err = buildCmd(rest)
	case "push":
		err = pushCmd(rest)
	case "rollback":
		err = rollbackCmd(rest)
	default:
		err = fmt.Errorf("unknown command %q", verb)
	}
	if err != nil {
		if errors.Is(err, flag.ErrHelp) {
			os.Exit(0)
		}
		fmt.Fprintln(os.Stderr, "ship:", err)
		os.Exit(1)
	}
}

Two decisions carry the weight here.

ContinueOnError, not ExitOnError. The package-level functions operate on flag.CommandLine, whose error mode is ExitOnError: a bad flag terminates the process from inside the library. In a subcommand you want the parse error returned so main decides the exit code and the message format. ContinueOnError returns flag.ErrHelp when the user typed -h, which is why the switch above treats it as success — help is not a failure, and a script checking $? should see 0.

The verb is stripped before parsing. buildCmd receives os.Args[2:], so ship build -tag v3 never makes build look like a positional argument. Inside, fs.Args() returns whatever non-flag tokens followed the flags — the natural place for a path operand, which Files, Buffers, and Paths then teaches you to open, stream, and validate.

Shared flags across verbs (say, -verbose on everything) do not need to be declared five times. A small struct registers itself into any set:

type common struct {
	verbose bool
}

func (c *common) register(fs *flag.FlagSet) {
	fs.BoolVar(&c.verbose, "verbose", false, "print extra detail")
}

Each command creates its own set, calls common.register(fs), and reads c.verbose after parsing. Because each verb owns a private registry, there is no redefinition panic and no collision between -tag meanings.

One trap remains: flags that two commands share but parse differently (a -timeout read as seconds in one and milliseconds in another) are a bug waiting for a reader. Keep shared flags registered through one helper so their meaning has a single home.

Precedence, Custom Values, and Exit Codes Scripts Branch On

An operator sets SHIP_PORT=9090 in the orchestrator. A developer runs the same binary locally with -port 3000. The README promises command line beats environment beats default — but the flag package knows nothing about environment variables, so the ordering is your code's job. The piece of the API you need is fs.Visit, which invokes a callback only for flags that were actually given on the command line (contrast fs.VisitAll, which walks every registered flag).

func resolvePort(fs *flag.FlagSet, flagPort *int) (int, error) {
	// Layer 1: default, baked into the flag declaration.
	port := 8080

	// Layer 2: environment, applied only if valid.
	if v, ok := os.LookupEnv("SHIP_PORT"); ok {
		n, err := strconv.Atoi(v)
		if err != nil {
			return 0, fmt.Errorf("SHIP_PORT must be an integer, got %q", v)
		}
		port = n
	}

	// Layer 3: command line, applied only if the flag was present.
	fs.Visit(func(f *flag.Flag) {
		if f.Name == "port" {
			port = *flagPort
		}
	})
	return port, nil
}

Reading the layers top to bottom reproduces the documented precedence: every later layer may overwrite the earlier one, but only when it has something to say. Note that a malformed environment variable is an error, not a shrug — silently falling back to a default after a typo hides the operator's intent and hands you a production incident with a wrong port.

When a flag's value needs validation or repeats, implement flag.Value yourself. The interface is two methods:

type stringList []string

func (l *stringList) String() string { return strings.Join(*l, ",") }

func (l *stringList) Set(s string) error {
	switch s {
	case "dev", "staging", "prod":
		*l = append(*l, s)
		return nil
	default:
		return fmt.Errorf("unknown environment %q (dev, staging, prod)", s)
	}
}

var envs stringList
fs.Var(&envs, "env", "target environment, repeatable")

Set runs during fs.Parse, so -env qa fails before the program does any work, with the message printed next to the usage block. String feeds flag.PrintDefaults, which is why returning the joined list shows a sensible default in help text. Repeatable flags fall out naturally: every occurrence calls Set again and appends.

Finally, exit codes are part of the contract. The flag package already exits 2 on a bad flag, so match it: 0 for success, 1 for a runtime failure the caller might retry, 2 for a usage error the caller must fix. A wrapper script that runs ship build -tag v3 || ship rollback is relying on that distinction to tell "the build broke" from "you typed the flag wrong." Error values stay ordinary Go values until the boundary — wrap them with %w as described in The error Interface and Error Messages — and let main translate them into a code once.

Quiz

1. A user runs `backup -src ./data report.txt -v`. The tool prints files (because `-src` worked) but never prints per-file detail. What happened?

Question 1 options

2. You are writing a parseFlags(args []string) helper inside a reusable package. Which FlagSet error mode should you use and why?

Question 2 options

3. A tool declares keep = flag.Int("keep", 30, ...), the operator exports KEEP_DAYS=7 in the environment, and the user passes no flag. The tool must use 7. Where does that composition happen?

Question 3 options

4. Two helper functions each call fs.String("verbose", ...) on the same FlagSet. What happens when the program starts?

Question 4 options

Flashcards

Question

After flag.Parse(), where do the non-flag arguments go, and what happens to flags that appear after them?

Answer

They are available via flag.Args() as a []string. Parsing stops at the first argument without a leading dash, so any dash-prefixed token after that point is also treated as a positional argument rather than a flag — a frequent source of silently ignored -v and -json flags.

Question

When do you create a flag.NewFlagSet instead of using flag.String and flag.Parse directly?

Answer

Whenever parsing must be scoped: subcommands that each own their flags, tests that invoke a parser with synthetic args, and library code that must return errors rather than exit. The package-level functions all operate on the shared flag.CommandLine set, so they collide across commands and hard-exit on bad input.

Question

What methods must a type implement to be registered with fs.Var, and what is each method used for?

Answer

flag.Value: String() string and Set(string) error. Set runs during parsing, which is where validation belongs — a bad value fails before any work starts. String reports the current value for flag.PrintDefaults and usage output, so returning the joined representation of a repeatable flag shows operators what a valid default looks like.

Question

How do you tell whether a flag was explicitly provided or merely holds its default value?

Answer

fs.Visit calls your callback only for flags the command line actually set; fs.VisitAll walks every registered flag. Visit is the tool for precedence logic such as command line over environment over default, because it distinguishes "user typed 8080" from "default is 8080" even when the values match.

Question

Why does a CLI conventionally exit with 2 for a bad flag and 1 for a failed operation?

Answer

The flag package already uses 2 for usage errors, and wrapper scripts branch on the distinction: 2 means fix your invocation, 1 means the work failed and a retry might be worthwhile, 0 means success. Matching the convention lets `tool build || tool rollback` distinguish a typo from a broken build.

Revision Notes

Key Takeaways

  • 1. Each flag constructor returns a pointer into storage the package owns; dereference after Parse, never before
  • 2. The default argument is the fallback for an omitted flag — zero values are never silently used as defaults
  • 3. Subcommands are one FlagSet per verb dispatched from os.Args[1], with ContinueOnError so main owns exit codes
  • 4. Precedence across default, environment, and command line is composed by your code using fs.Visit
  • 5. Custom flag.Value types move validation into parsing, so invalid input fails before any side effects

Interview Tips

  • • "How would you build a CLI with subcommands in Go?" — name the FlagSet-per-verb dispatch, say why ContinueOnError, then mention flag.Args() for operands and the shared-flag registration helper
  • • "What happens to flags after a positional argument?" — answer with the exact behavior: parsing stops, everything after joins flag.Args(); follow with the UX consequence and how you would fix it
  • • "Design flag/environment/default precedence" — walk the three layers and use fs.Visit as the mechanism that distinguishes an explicit flag from a defaulted one
  • • Bring exit codes unprompted: 0/1/2 and why a script needs to tell a usage error from an operational failure

Cheat Sheet

Command-Line Flags with the flag Package

Choose the registration style:

Situation API Note
Simple typed flag with default flag.String/Int/Bool/Duration returns *T, default shows in usage
Subcommand or test parsing flag.NewFlagSet(name, flag.ContinueOnError) returns errors instead of exiting
Repeatable or validated flag fs.Var(&v, name, usage) implements flag.Value
Bind into an existing variable fs.IntVar(&x, ...) x must already hold the default

Precedence (you compose it): default → environment (only if LookupEnv ok) → command line (only if fs.Visit sees the flag). The flag package never reads the environment.

Exit codes: 0 success, 1 runtime failure, 2 usage error (flag package already exits 2 on bad flags). -h prints usage and exits 0.

Parsing rule: flags must precede positional args; parsing stops at the first non-dash token and the rest land in flag.Args().

Code template:

func serveCmd(args []string) error {
	fs := flag.NewFlagSet("serve", flag.ContinueOnError)
	addr := fs.String("addr", ":8080", "listen address")
	if err := fs.Parse(args); err != nil {
		return err
	}
	return serve(*addr, fs.Args()...)
}

Continue Learning

Before You Start

Related Reading