The command line needs structure
You wrote a script that processes data. It works. Now you need it to accept a filename, enable verbose mode, or set a retry count. Hardcoding values works for a demo, but a real tool needs input. Command-line flags and arguments are the standard way to pass configuration to a program running in a terminal.
Go splits command-line input into two buckets. Flags are named options with types, like -name=Go or -count=5. They act like form fields: the name tells you what it is, and the type tells you how to parse it. Arguments are positional values that come after the flags, like file.txt in cat file.txt. They are just strings in a list.
The flag package handles flags. The os package handles arguments. You usually use both together. The flag package gives you type safety, default values, and automatic help text. It also enforces a strict parsing order that prevents silent bugs.
Why pointers?
The flag package returns pointers, not values. This design choice often surprises developers coming from languages where configuration is a simple variable.
When you call flag.String("name", "World", "Name to greet"), the function allocates a string on the heap and returns a pointer to it. The flag package stores the pointer internally. When flag.Parse runs, it reads the command line and writes the result directly into that pointer.
This avoids global variables. You can pass the pointer to other functions, and they see the parsed value. It also keeps the parsing logic separate from the variable declaration. If flag.String returned a value, flag.Parse would have no way to update it without returning a new value, which would force a different API design. Pointers let Go keep the interface simple and the state mutable.
Always dereference the pointer when you use the value. Printing the pointer gives you a memory address. The compiler allows this because pointers are valid values. You have to do the dereference manually.
Minimal example
Here's the simplest flag setup: define a string flag, parse, and print.
package main
import (
"flag"
"fmt"
)
// main starts the CLI and reads the name flag.
func main() {
// flag.String returns a pointer to the value.
// The second argument is the default. The third is the help text.
name := flag.String("name", "World", "Name to greet")
// flag.Parse must run before accessing flag values.
// It reads os.Args and populates the pointers.
flag.Parse()
fmt.Printf("Hello, %s!\n", *name)
}
Run this with go run main.go -name=Go and it prints Hello, Go!. Run it with no arguments and it prints Hello, World!. The default value kicks in when the flag is absent.
Parse flags before you read them. The pointer stays default until flag.Parse runs.
The flags-first rule
The flag package stops parsing at the first non-flag argument. This is a hard rule. If you run tool file.txt -v, the parser sees file.txt, decides it's not a flag, and stops. The -v is treated as a positional argument, not a flag. The verbose mode never enables.
Flags must come before arguments. This design supports tools where the first argument is a subcommand or a file, and flags modify the behavior of that argument. It also prevents ambiguity. If flags could appear anywhere, the parser would need to guess whether -v is a flag or a filename starting with a dash. Go chooses simplicity: flags first, then arguments.
If you need to support arguments before flags, you have to parse os.Args manually. The flag package does not support that pattern.
Flags come first. Arguments come second. Order matters.
Realistic example
Real tools have multiple flags and often positional arguments. Here's a pattern for a tool that takes a count flag and a list of files.
package main
import (
"flag"
"fmt"
"os"
)
// main initializes flags and parses arguments.
func main() {
// -v enables verbose mode. Default is false.
verbose := flag.Bool("v", false, "Enable verbose output")
// -retries sets retry count. Default is 3.
retries := flag.Int("retries", 3, "Number of retry attempts")
// flag.Parse processes os.Args and updates the pointers.
flag.Parse()
// flag.Args returns the slice of positional arguments.
files := flag.Args()
if len(files) == 0 {
fmt.Fprintln(os.Stderr, "Error: no files provided")
flag.Usage()
os.Exit(1)
}
// Process files using the parsed configuration.
processFiles(files, *verbose, *retries)
}
// processFiles iterates over files and prints status.
func processFiles(files []string, verbose bool, retries int) {
for _, file := range files {
if verbose {
fmt.Printf("Processing %s (retries: %d)\n", file, retries)
} else {
fmt.Println(file)
}
}
}
This example shows three common patterns. The boolean flag uses a short name -v. The integer flag uses a long name -retries. The flag.Args call captures the remaining arguments after parsing. The code checks for missing arguments and prints an error to stderr.
The flag.Usage call prints the help text. It uses the help strings you passed to flag.Bool and flag.Int. You don't need to write your own help text. The package generates it from the definitions.
Let flag.Usage write the help text. You defined the strings; the package does the rest.
Pitfalls and errors
The flag package is strict. It protects you from misconfiguration by failing fast.
If you pass a flag that wasn't defined, the program crashes. The compiler won't catch this because flags are strings at runtime. You get a runtime panic: flag: help requested if you pass -h, or flag: looking for string flag -name: not found if you typo a flag. The flag package prints usage and exits on unknown flags by default. This is safe behavior. It prevents silent misconfiguration.
If you pass a value with the wrong type, the parser rejects it. Passing abc to an integer flag triggers flag: invalid value "abc" for -retries: parse error. The program exits with a usage message. You don't need to write type-checking logic. The package handles it.
The flag.Parse function returns an error. In most CLI programs, you ignore it because flag.Parse already exits on errors. If you want to handle errors gracefully, check the return value. This is rare in simple tools but useful in libraries.
The flag package also handles short flags automatically. If you define flag.Bool("v", false, "Verbose"), users can pass -v or -v=true. The package parses both forms. You don't need to define separate flags for short and long names.
The worst flag bug is the one that never logs. Always call flag.Parse and check flag.Args if you expect arguments.
Convention asides
The Go community follows a few conventions around flags.
The help text should be a sentence fragment starting with a capital letter and ending with a period. The flag package formats these into a readable block. Keep help text concise. Users scan it quickly.
Short flags are usually single letters. Long flags are descriptive names. Use -v for verbose, not -verbose. Use -retries for retry count, not -r. This matches Unix conventions.
The flag package prints help to stderr. This is standard. Help text is diagnostic information, not program output. If you redirect stdout to a file, help text still appears on the terminal.
Receiver names in functions that use flags are usually one or two letters matching the type. This doesn't apply to flags directly, but if you wrap flags in a struct, follow the naming convention.
Don't pass a *string to functions that expect a string. Dereference the flag pointer before passing it to other functions. This keeps the interface clean.
Decision matrix
Use the flag package when you need typed flags with automatic help generation and error handling. Use os.Args directly when you are writing a thin wrapper that forwards arguments to another binary. Use a third-party library like cobra when you need subcommands, auto-completion, or complex nested flag structures. Use environment variables when the configuration is sensitive or shared across multiple invocations.