How to Parse Command-Line Flags in Go

Cli
Parse command-line flags in Go using the standard `flag` package to define variables and call `flag.Parse()`.

When positional arguments stop working

You are building a command-line tool that needs a configuration file, a verbosity level, and a list of input paths. You start by splitting os.Args manually. It works when users pass three arguments in the exact order you expected. It breaks the moment someone swaps them or adds a fourth. You switch to the standard library flag package. The manual index math disappears. The parser handles ordering, type validation, and built-in help text automatically.

How the flag package actually works

The flag package treats command-line input as a collection of named keys. Each key expects a specific type. You declare the keys before parsing, pass pointers to variables that will hold the results, and call flag.Parse() to populate them. Think of it like a mail sorter at a distribution center. You label the mailboxes with names and expected contents. The sorter reads each envelope, checks the label, verifies the contents match the expected type, and drops it in the right box. If an envelope arrives with a mismatched label or invalid contents, the sorter stops and reports the problem.

The package relies on pointers because it needs a place to write the parsed values. flag.String("name", "default", "description") returns a *string. The function allocates the string on the heap, sets it to the default, and hands you the address. When parsing finishes, the package dereferences that address and writes the user input directly into it. You never allocate the final value yourself. The flag package owns the allocation and hands you the reference.

The minimal parser

Here is the simplest working setup. It defines two flags, parses the input, and prints a greeting the requested number of times.

package main

import (
	"flag"
	"fmt"
)

func main() {
	// flag.String returns a pointer to a pre-allocated string.
	// The package sets the initial value to "World" before parsing.
	name := flag.String("name", "World", "A greeting name")
	
	// flag.Int returns a pointer to a pre-allocated integer.
	// Default is 1. The description populates the built-in help text.
	count := flag.Int("count", 1, "Number of greetings")
	
	// Scans os.Args, matches keys to definitions, and writes results.
	// Stops at the first non-flag argument or after processing all flags.
	flag.Parse()
	
	// Dereference the pointers to read the final parsed values.
	// The loop runs exactly *count times using the resolved name.
	for i := 0; i < *count; i++ {
		fmt.Printf("Hello, %s\n", *name)
	}
}

Run it with go run main.go -name=Go -count=3. The output prints three lines. Change the order to -count=3 -name=Go and the result stays identical. The parser does not care about argument order.

Flags are configuration, not logic. Keep them at the edges. Parse early, pass values inward, and let the core functions work with plain types.

What happens under the hood

The execution flow splits into two distinct phases. During the definition phase, flag.String and flag.Int register their handlers with the global flag.CommandLine set. Each handler knows how to convert a string slice into the target type and where to write it. Nothing runs yet. The pointers just point to the default values.

When flag.Parse() executes, the package iterates over os.Args[1:]. It strips the leading dash, looks up the key in its registry, and calls the registered setter. If the key matches, the setter converts the value, validates it, and writes it to the pointer. If the key is unknown, the parser calls flag.Usage and exits with a non-zero status. If the value fails type conversion, the setter prints a usage message and calls os.Exit(2).

The package stops scanning for flags when it encounters an argument that does not start with a dash, or when it reaches --. Everything after that point lands in flag.Args(), which holds positional arguments. This separation keeps named configuration separate from raw input data.

The standard library favors explicit failure. The flag package exits on bad input because CLI tools usually run in scripts or terminals where immediate feedback is more useful than deferred errors. If you are building a long-running daemon, switch to flag.ContinueOnError and handle the error in your application logic.

A realistic configuration setup

Real tools rarely stop at two flags. They need to handle slices, durations, custom types, and validation. The flag package supports custom types through the flag.Value interface. Any type that implements String() string and Set(string) error can be registered.

Here is the custom type definition. It parses comma-separated values and validates them.

package main

import (
	"flag"
	"strings"
)

// StringSlice implements flag.Value to parse comma-separated inputs.
type StringSlice []string

// Set splits the input string and appends each item to the slice.
// Trimming whitespace prevents accidental empty entries from spaces.
func (s *StringSlice) Set(value string) error {
	for _, item := range strings.Split(value, ",") {
		trimmed := strings.TrimSpace(item)
		if trimmed != "" {
			*s = append(*s, trimmed)
		}
	}
	return nil
}

// String returns the slice as a comma-separated representation.
// The flag package calls this when generating help text or printing defaults.
func (s *StringSlice) String() string {
	return strings.Join(*s, ",")
}

The custom type pattern keeps parsing logic isolated. The Set method handles conversion and validation. The String method provides a fallback representation for help text. This matches the standard library philosophy: small interfaces, explicit contracts, and no hidden magic.

Here is the main function that wires everything together.

package main

import (
	"fmt"
	"time"
)

func main() {
	// Register the custom flag type for comma-separated values.
	// Default is an empty slice. The pointer holds the live data.
	levels := &StringSlice{}
	flag.Var(levels, "level", "Comma-separated log levels")
	
	// flag.Duration parses time strings like "30s" or "1h2m".
	// Default is 5 seconds. Pointer stores the parsed duration.
	timeout := flag.Duration("timeout", 5*time.Second, "Request timeout")
	
	flag.Parse()
	
	// flag.Args() captures everything after the flags or after --.
	// Useful for file paths or positional inputs that don't need names.
	files := flag.Args()
	
	fmt.Printf("Levels: %s\n", *levels)
	fmt.Printf("Timeout: %v\n", *timeout)
	fmt.Printf("Files: %v\n", files)
}

Flag naming follows standard library conventions. Use lowercase letters and hyphens for keys. Descriptions start with a capital letter and end with a period. The package automatically generates help text when you pass -h or --help. Override flag.Usage only if you need custom formatting. Otherwise, trust the default output.

Common traps and compiler behavior

The flag package enforces strict ordering of operations. You must define all flags before calling flag.Parse(). If you define a flag after parsing, the package ignores it silently. The parser only scans the global flag.CommandLine set at the moment Parse() runs.

Type mismatches trigger immediate exits. Pass -count=abc and the program prints invalid value "abc" for flag -count: parse error. The package does not return errors to your code. It calls os.Exit(2) directly. If you need graceful error handling, you must override flag.ErrorHandling or use flag.NewFlagSet with flag.ContinueOnError.

// Using NewFlagSet gives control over error handling.
// flag.ContinueOnError prevents automatic os.Exit calls.
fs := flag.NewFlagSet("myapp", flag.ContinueOnError)
count := fs.Int("count", 1, "Number of greetings")
err := fs.Parse(os.Args[1:])
if err != nil {
	// Handle the error gracefully instead of exiting.
	// Log the problem, show usage, and return a non-zero code.
}

Another common trap involves pointer dereferencing. Forgetting the * operator when reading a flag value passes the memory address instead of the value. The compiler catches type mismatches early. If you pass count instead of *count to a function expecting an int, you get cannot use count (variable of type *int) as int value in argument. Always dereference when reading, and keep the pointer when passing to the flag package.

Global state is another consideration. flag.String and flag.Parse operate on flag.CommandLine, a singleton shared across your entire program. This works fine for simple tools. It breaks when you build subcommands or test functions that need isolated parsers. flag.NewFlagSet creates independent sets. Each set has its own definitions, its own Parse method, and its own Args() slice.

Developers often confuse flag.Visit with flag.VisitAll. flag.Visit iterates only over flags that the user actually passed on the command line. flag.VisitAll iterates over every registered flag, regardless of whether it was used. Pick Visit when you need to detect what changed from the defaults. Pick VisitAll when you need to print a summary of the entire configuration.

The flag package does exactly what it promises. No hidden state, no reflection tricks, no dependency tree. Define, parse, dereference, run.

Choosing the right parser

Use the standard flag package when you are building a simple utility with fewer than ten options and want zero external dependencies. Use flag.NewFlagSet when you need isolated parsers for subcommands or want to catch errors without calling os.Exit. Use a third-party library like pflag or urfave/cli when your tool requires nested subcommands, config file merging, or complex help formatting. Use plain os.Args slicing when you only need positional arguments and want to avoid flag parsing overhead entirely.

Where to go next