The friction of hardcoded values
You wrote a script that processes a CSV file. It works perfectly on your machine. You send the binary to a teammate, and they have to open the source code, change the filename string, and recompile just to run it on their data. That friction kills momentum. You need a way to pass configuration from the outside without touching the binary. Command-line flags solve this. They turn a static program into a tool anyone can configure on the fly.
The flag package pattern
Go ships with the flag package in the standard library. It handles parsing command-line arguments and binding them to variables. The design is intentionally minimal. You define flags, call Parse, and the variables get populated. If a user passes garbage, the program exits with a usage message. No external dependencies, no complex configuration files for simple tasks.
The package operates on a global flag set by default. This works for almost every standalone tool. You register flags using functions like StringVar or IntVar, which take a pointer to a variable, a name, a default value, and a usage string. After registration, flag.Parse reads os.Args, matches arguments to flags, and updates the variables.
Convention aside: flag is the baseline for Go CLIs. For simple tools, it is the right choice. The community expects flag behavior: -h prints help, unknown flags cause an error, and defaults are explicit. If your tool grows subcommands or needs auto-generated docs, you graduate to cobra or urfave/cli. Start with flag to understand the mechanics.
Minimal example
Here's the canonical pattern: define variables, register flags pointing to them, parse, and use.
package main
import (
"flag"
"fmt"
)
func main() {
// Pointers let flag modify the variable directly.
var name string
var count int
// StringVar binds the -name flag to the name variable.
// The third argument is the default value.
// The fourth is the help text shown with -h.
flag.StringVar(&name, "name", "world", "target for greeting")
flag.IntVar(&count, "count", 1, "number of greetings")
// Parse reads os.Args and populates the variables.
// It also handles -h and -help automatically.
flag.Parse()
for i := 0; i < count; i++ {
fmt.Printf("Hello, %s!\n", name)
}
}
Run this with go run main.go -name=alice -count=3. The output prints the greeting three times. Run it with -h and you get a formatted help message listing all flags, their defaults, and descriptions.
Walkthrough
When the program starts, flag.Parse looks at os.Args. It strips the program name and scans for arguments starting with -. If it finds -name=alice, it updates the name variable. If it finds -count=3, it updates count. Arguments without a leading dash are treated as positional arguments and stored in flag.Args().
The package validates types automatically. Passing -count=abc triggers a runtime error. The program prints flag: help requested is not the error here; you get flag: parsing flag arg help failed: strconv.ParseInt: parsing "abc": invalid syntax followed by usage, and the process exits. This strictness prevents silent failures.
Positional arguments are captured after parsing. If you run the example with go run main.go -name=bob extra1 extra2, the flags are consumed, and flag.Args() returns ["extra1", "extra2"]. flag.NArg() returns the count. This separation keeps flags and data distinct.
Convention aside: flag.Parse is usually called once at the top of main. Calling it multiple times is allowed but rare; it accumulates flags and re-parses. The standard idiom is define, parse, run.
Realistic tool with validation
Real tools often have required flags and validation logic. This example shows a verbose switch, a port number, and a mandatory filename.
package main
import (
"flag"
"fmt"
"os"
)
// main sets up flags, parses input, and runs the logic.
func main() {
// Verbose flag controls logging detail.
// BoolVar uses a pointer to a bool.
var verbose bool
flag.BoolVar(&verbose, "v", false, "enable verbose output")
// Port flag with a default.
// IntVar ensures the value is an integer.
var port int
flag.IntVar(&port, "port", 8080, "server port")
// Filename is required for this tool.
// Empty default signals the user must provide it.
var filename string
flag.StringVar(&filename, "f", "", "input file path")
flag.Parse()
// Check required flags after parsing.
if filename == "" {
fmt.Fprintln(os.Stderr, "error: -f is required")
flag.Usage()
os.Exit(1)
}
if verbose {
fmt.Printf("Processing %s on port %d\n", filename, port)
}
// Logic goes here.
fmt.Printf("Ready with %s\n", filename)
}
The validation happens after flag.Parse. The package only enforces types and known flags. It does not enforce business rules. Checking filename == "" catches missing required inputs. Calling flag.Usage() prints the help text, and os.Exit(1) signals failure. This pattern keeps the happy path clean while handling errors explicitly.
Validate required flags after Parse. Don't trust the user.
Isolating flags with NewFlagSet
The global flag functions operate on flag.CommandLine. This is a singleton. It works fine for main. If you are writing a library that defines flags, or you want to parse a slice of arguments manually, use flag.NewFlagSet. It returns a *flag.FlagSet that you can parse independently. This prevents flag collisions and allows testing.
package main
import (
"flag"
"fmt"
)
func main() {
// NewFlagSet creates an isolated flag set.
// flag.ContinueOnError returns errors instead of exiting.
fs := flag.NewFlagSet("test", flag.ContinueOnError)
var debug bool
fs.BoolVar(&debug, "debug", false, "enable debug mode")
// Parse specific args, not os.Args.
// This allows testing or subprocess flag handling.
err := fs.Parse([]string{"-debug=true"})
if err != nil {
fmt.Println("parse error:", err)
return
}
fmt.Println("debug is", debug)
}
The flag.ContinueOnError option changes error handling. Instead of printing usage and exiting, Parse returns the error. This is essential for libraries that shouldn't control the process lifecycle, or for tests that verify error paths. The flag.ExitOnError option restores the default behavior.
Convention aside: Libraries should not use global flags. They should use NewFlagSet or accept configuration structs. Global flags couple your code to os.Args and make testing harder. Keep flag parsing in the application layer.
Pitfalls and error handling
If a user passes an undefined flag like -unknown, the program exits with flag: unknown flag -unknown. This is a hard stop. The package does not ignore unknown flags. If you need to pass flags to a subprocess, use -- to stop parsing, or handle flag.Args(). The double dash tells flag to treat everything after it as positional arguments.
Passing -h triggers flag.ErrHelp. The default behavior prints usage and exits with code 2. If you use ContinueOnError, you get the error back and can handle it gracefully. This distinction matters when you want custom exit codes or logging.
Forgetting flag.Parse is a silent bug. The compiler won't catch this. Variables stay at their defaults, and the program runs with stale configuration. Always call Parse before using flag values.
Using flag.String versus flag.StringVar is a style choice. flag.String returns a pointer to the value. flag.StringVar takes a pointer to a variable. Both work. StringVar is often cleaner when you have a local variable and want to avoid dereferencing.
Unknown flags crash the program. That's a feature, not a bug.
Decision matrix
Use the flag package when you need a simple CLI with a handful of options and want zero dependencies. Use flag.Args() when you need to capture positional arguments that don't start with a dash. Use flag.BoolVar with a short name like -v when users will type the flag frequently. Use a custom flag.Usage function when the default help text isn't enough for your tool's complexity. Reach for cobra or urfave/cli when you need subcommands, auto-generated docs, or complex flag grouping. Use os.Args directly when you need non-standard parsing behavior that flag doesn't support.
Start with flag. Graduate when you hit its limits.