{} The Go Reference

System · Stdlib · Beginner

CLI Tools with flag

Build command-line tools with the flag package — typed options with defaults, binding with Var, positional args, subcommands via FlagSet, custom flag.Value types, and env-var fallbacks.

System Beginner ⏱ 6 min read Complete

🎛️ Analogy

os.Args is the raw stream of words typed after your program’s name — a pile of unsorted mail. The flag package is the mail sorter: you declare the slots you expect (--name, --count, --verbose), call flag.Parse(), and it files each argument into the right typed variable, applies your defaults, and leaves the leftover positionals in a neat stack.

Declaring flags

Each flag.String/Int/Bool takes a name, a default, and a usage string, and returns a pointer that flag.Parse() fills in. Read the value through the pointer afterward:

graph LR
ARGS["os.Args[1:]<br/>--name go --count 3 file.txt"] --> PARSE["flag.Parse()"]
PARSE --> VARS["*name, *count, *verbose<br/>(typed, with defaults)"]
PARSE --> POS["flag.Args()<br/>positional: file.txt"]

This program defines three flags. With no arguments — as on the playground — the defaults stand, so it runs deterministically:

flags.go — editable & runnable
package main

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

func main() {
// Define flags: name, default value, usage string.
name := flag.String("name", "world", "who to greet")
count := flag.Int("count", 1, "how many times")
upper := flag.Bool("upper", false, "shout the greeting")

flag.Parse() // parses os.Args[1:]; with no args, defaults stand

greeting := "hello, " + *name
for i := 0; i < *count; i++ {
	if *upper {
		fmt.Println("HELLO, " + *name + "!")
	} else {
		fmt.Println(greeting)
	}
}

// Positional args (after the flags) live in flag.Args().
fmt.Printf("positional args: %v\n", flag.Args())

// Environment with a fallback default.
mode := os.Getenv("APP_MODE")
if mode == "" {
	mode = "dev"
}
fmt.Println("mode:", mode)
}

Run as tool --name go --count 2 report.txt and you’d get two hello, go lines plus [report.txt] as the positional. The built-in --help flag prints every usage string for free.

🧪 Var binds a flag to an existing variable

Instead of the pointer-returning flag.Int, the …Var forms bind to a variable you already have: flag.IntVar(&cfg.Count, "count", 1, "…"). That’s the clean way to populate a config struct’s fields directly, so the rest of your program reads cfg.Count, not *count.

Positionals and env-var fallbacks

After parsing, positional arguments — the words that aren’t flags — sit in flag.Args():

flag.Args()  // []string of leftovers, e.g. ["report.txt"]
flag.Arg(0)  // "report.txt"
flag.NArg()  // 1

For configuration that shouldn’t live on the command line (secrets, deploy mode), read an environment variable with a fallback via os.Getenv. A common idiom is “flag overrides env overrides default”:

mode := os.Getenv("APP_MODE")
if mode == "" {
	mode = "dev" // sensible default
}

Subcommands with FlagSet

The global functions (flag.String, flag.Parse) operate on one default flag set. When a binary needs several commands (tool add, tool list), give each its own flag.NewFlagSet and parse the arguments after the command word. This playground parses a hard-coded argument slice so it runs deterministically; in a real tool you’d pass os.Args[2:]:

subcommands.go — editable & runnable
package main

import (
"flag"
"fmt"
)

func main() {
// Each subcommand owns its own flags.
addCmd := flag.NewFlagSet("add", flag.ExitOnError)
prio := addCmd.Int("priority", 1, "task priority")

listCmd := flag.NewFlagSet("list", flag.ExitOnError)
all := listCmd.Bool("all", false, "include done tasks")

// Normally: command, args := os.Args[1], os.Args[2:]
command := "add"
args := []string{"-priority", "5", "buy", "milk"}

switch command {
case "add":
	addCmd.Parse(args)
	fmt.Printf("add: priority=%d task=%v\n", *prio, addCmd.Args())
case "list":
	listCmd.Parse(args)
	fmt.Printf("list: all=%v\n", *all)
default:
	fmt.Println("usage: tool <add|list> [flags]")
}
}

Custom flag types

A flag’s type isn’t limited to the built-ins. Anything implementing flag.ValueString() string and Set(string) error — can be a flag via flag.Var. This is how you accept a comma-separated list, a validated enum, or (with flag.Duration) a time.Duration. Set receives the raw text and can reject it:

custom.go — editable & runnable
package main

import (
"flag"
"fmt"
"strings"
)

// tagList implements flag.Value to accept -tags a,b,c.
type tagList []string

func (t *tagList) String() string { return strings.Join(*t, ",") }

func (t *tagList) Set(v string) error {
if v == "" {
	return fmt.Errorf("tags cannot be empty")
}
*t = strings.Split(v, ",")
return nil
}

func main() {
var tags tagList
fs := flag.NewFlagSet("demo", flag.ContinueOnError)
fs.Var(&tags, "tags", "comma-separated tags")

fs.Parse([]string{"-tags", "go,cli,stdlib"})
fmt.Printf("%d tags: %v\n", len(tags), []string(tags))
}

Reference

NeedUse
A string/int/bool optionflag.String/Int/Bool(name, def, usage)
Bind to an existing variableflag.StringVar(&v, …)
Parse the command lineflag.Parse()
Leftover positionalsflag.Args() / flag.Arg(0) / flag.NArg()
Separate command groupsflag.NewFlagSet(name, errHandling)
A custom-typed flagimplement flag.Value, register with flag.Var
Config from environmentos.Getenv / os.LookupEnv with a default
Custom help textoverride flag.Usage

⚠️ Flags must come before positionals

The standard flag parser stops at the first non-flag argument. So tool file.txt --count 3 treats --count 3 as positionals, not flags — *count keeps its default. Put flags first (tool --count 3 file.txt), or use a bare -- to force the end of flags. If you need flags interspersed anywhere, or rich subcommands and shell completion, that’s where a third-party parser (spf13/cobra + pflag) earns its keep — but reach for it only when the stdlib flag genuinely isn’t enough.

See also

Next: replace ad-hoc fmt.Println debugging with real logs — Structured Logging with slog.

Check your understanding

Score: 0 / 5

1. Why do flag.String / flag.Int return a *pointer* instead of the value?

When you call flag.String("name", ...) the flag isn't parsed yet. flag returns a *string it will populate during flag.Parse(); you read the value through the pointer (*name) afterward.

2. After flag.Parse(), where do the leftover non-flag arguments go?

flag.Parse() consumes the recognized flags and leaves the rest in flag.Args() (a []string). flag.Arg(0) and flag.NArg() give individual access — handy for a filename or command word.

3. How do you give a tool subcommands like `git commit` and `git push`?

The global flag set handles one set of options. For subcommands, switch on os.Args[1], then call a dedicated flag.NewFlagSet(name, ...).Parse(os.Args[2:]) — each subcommand gets its own flags.

4. What does implementing the flag.Value interface (String() + Set(string) error) let you do?

flag.Var(&myValue, name, usage) accepts anything implementing flag.Value. Set is called with the raw argument string (return an error to reject it); String renders the default. That's how you get -tags a,b,c or a duration flag.

5. Where does flag print its auto-generated usage, and how do you trigger it?

flag wires up -h/--help for free and prints usage (to stderr) on any parse error, assembling it from the name, type, default, and usage string of every defined flag. Override flag.Usage to customize it.

Comments

Sign in with GitHub to join the discussion.