Go Command-Line Flags and Options with the flag Package
Golang 12 min read
Why flags after a positional argument are silently ignored, single and double dashes being equivalent, custom flag types, and FlagSet for subcommands.
The flag package parses command-line options in about four lines, and it has one behaviour that
surprises everyone coming from other ecosystems: parsing stops at the first non-flag argument. A
flag written after a filename is not an error. It is a positional argument, and the program runs with
the default.
Written against Go 1.22.
The basics
package main
import (
"flag"
"fmt"
)
func main() {
port := flag.Int("port", 8080, "port to listen on")
host := flag.String("host", "localhost", "host to bind")
debug := flag.Bool("debug", false, "enable debug logging")
flag.Parse()
fmt.Printf("%s:%d debug=%v\n", *host, *port, *debug)
}
go run main.go -port 9000 -debug
# localhost:9000 debug=true
Each function returns a pointer, and the value is only valid after flag.Parse(). Reading
*port before parsing gives the default, which is a real bug in code that initialises a package-level
variable from a flag.
The Var forms bind to an existing variable instead, which reads better when the values live in a
config struct:
var cfg struct {
Port int
Host string
Debug bool
}
flag.IntVar(&cfg.Port, "port", 8080, "port to listen on")
flag.StringVar(&cfg.Host, "host", "localhost", "host to bind")
flag.BoolVar(&cfg.Debug, "debug", false, "enable debug logging")
flag.Parse()
Syntax the package accepts
-port 9000 # space-separated
-port=9000 # equals
--port 9000 # double dash — identical to single
--port=9000
-debug # boolean, implied true
-debug=false # boolean, explicit
One and two dashes mean the same thing; there is no distinction between short and long options, and
there is no combining: -abc is a flag named abc, not three boolean flags.
Booleans are the exception to the spacing rule. -debug true does not work: the parser takes
-debug as true and true as a positional argument. Boolean flags require the = form to be set
explicitly, and that asymmetry is worth remembering because it fails silently.
Parsing stops at the first positional argument
go run main.go input.txt -debug
-debug is false here. flag.Parse stops at input.txt, and everything after it, including
things that look like flags, becomes a positional argument.
flag.Parse()
fmt.Println(flag.Args()) // ["input.txt", "-debug"]
fmt.Println(flag.NArg()) // 2
fmt.Println(flag.Arg(0)) // input.txt
This is deliberate and matches the older Plan 9 convention rather than GNU getopt, which permutes
its arguments so flags can appear anywhere. Neither is wrong; only one matches what most users expect
in 2026.
There is no option to change it. The practical responses are to document that flags come first, to
validate that no element of flag.Args() begins with a dash, or to use a third-party parser such as
spf13/pflag when GNU-style behaviour is required.
for _, arg := range flag.Args() {
if strings.HasPrefix(arg, "-") {
fmt.Fprintf(os.Stderr, "flags must appear before arguments: %s\n", arg)
os.Exit(2)
}
}
Note also that os.Args includes the program name at index 0 while flag.Args() does not. They are
different lists, and mixing them up is an off-by-one.
Usage output and errors
go run main.go -h
Usage of /tmp/go-build/main:
-debug
enable debug logging
-host string
host to bind (default "localhost")
-port int
port to listen on (default 8080)
Generated from the flag descriptions and sorted alphabetically. An unknown flag prints the same text
and exits with status 2, which is correct behaviour but happens inside flag.Parse(), so nothing
after it runs, including any deferred cleanup registered before it.
The type name in the output, -host string, comes from the flag’s type, and for a custom
flag.Value it is inferred from the description: a word in backquotes becomes the placeholder, so
"path to the +“file”+" renders as -out file.
To handle errors yourself, use a FlagSet with ContinueOnError:
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
port := fs.Int("port", 8080, "port to listen on")
if err := fs.Parse(os.Args[1:]); err != nil {
return err // instead of exiting
}
flag.Usage is a variable, so replacing it customises the help:
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: %s [options] <input-file>\n\n", os.Args[0])
flag.PrintDefaults()
}
Custom flag types
Anything implementing flag.Value, meaning String() string and Set(string) error, can be a flag. That
covers the two cases the built-in types do not: a repeated flag, and a value that needs validating at
parse time.
type stringList []string
func (s *stringList) String() string { return strings.Join(*s, ",") }
func (s *stringList) Set(value string) error {
if value == "" {
return fmt.Errorf("empty value")
}
*s = append(*s, value)
return nil
}
var tags stringList
flag.Var(&tags, "tag", "tag to apply (repeatable)")
flag.Parse()
// -tag one -tag two -> ["one", "two"]
Set is called once per occurrence, so appending is what makes a flag repeatable. Returning an error
from it produces a proper usage message rather than a panic somewhere later.
flag.Duration and flag.Func cover the common cases without a type:
timeout := flag.Duration("timeout", 30*time.Second, "request timeout") // accepts "1m30s"
flag.Func("level", "log level", func(s string) error {
if !slices.Contains([]string{"debug", "info", "warn"}, s) {
return fmt.Errorf("invalid level %q", s)
}
level = s
return nil
})
Subcommands
flag has no subcommand support, and FlagSet is how it is usually built:
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "expected 'serve' or 'migrate'")
os.Exit(2)
}
switch os.Args[1] {
case "serve":
fs := flag.NewFlagSet("serve", flag.ExitOnError)
port := fs.Int("port", 8080, "port to listen on")
fs.Parse(os.Args[2:])
serve(*port)
case "migrate":
fs := flag.NewFlagSet("migrate", flag.ExitOnError)
steps := fs.Int("steps", 0, "number of migrations to apply")
fs.Parse(os.Args[2:])
migrate(*steps)
default:
fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
os.Exit(2)
}
}
Each FlagSet parses from os.Args[2:], skipping the program name and the subcommand. Past three or
four subcommands this becomes enough boilerplate to justify cobra or urfave/cli.
Combining flags with environment variables
Configuration usually needs to come from more than one place: a flag for interactive use, an environment variable for a container. The standard library has no layering, so the pattern is to default the flag from the environment:
func envOr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
port := flag.String("port", envOr("PORT", "8080"), "port to listen on")
Order matters and this expression gets it right: the flag default is computed from the environment, so an explicit flag still wins. Doing it the other way, parsing flags and then overwriting from the environment, makes the environment win, which surprises anyone who passed the flag deliberately.
LookupEnv rather than Getenv is deliberate too: it distinguishes an unset variable from one set
to the empty string, which is the difference between “use the default” and “the operator explicitly
set this to nothing”.
Testing a command line
Because flags are package-level state, tests interfere with each other unless each builds its own
FlagSet:
func parseArgs(args []string) (*config, error) {
fs := flag.NewFlagSet("app", flag.ContinueOnError)
fs.SetOutput(io.Discard) // keep usage text out of test output
cfg := &config{}
fs.IntVar(&cfg.Port, "port", 8080, "port")
if err := fs.Parse(args); err != nil {
return nil, err
}
return cfg, nil
}
main then calls parseArgs(os.Args[1:]) and a test calls it with a literal slice. That single
refactoring, taking the argument slice as a parameter rather than reading the global, is what makes
argument handling testable at all, and it is worth doing before the flag list grows.
Related: reading command-line arguments and environment variables, which is where configuration usually belongs once there is more than a handful. More in the Golang guides.
Frequently asked questions
Why is my flag ignored when it comes after a filename?
flag.Parse stops at the first non-flag
argument. Everything after it, including things starting with a dash, becomes a positional argument.
Can I make flags work after positional arguments?
Not with the standard library. Document the
order, validate flag.Args() for stray dashes, or use spf13/pflag for GNU-style permutation.
Is there a difference between -flag and —flag?
No. The package treats one and two dashes identically, and there are no combined short flags.
Why does -debug true not work?
Boolean flags do not take a following value, true becomes a
positional argument. Use -debug=true.
Why is my flag variable empty?
It was read before flag.Parse() ran. The pointer only holds the
parsed value afterwards, which catches package-level initialisation.
How do I allow a flag to be repeated?
Implement flag.Value and append in Set, which is called
once per occurrence, then register it with flag.Var.
How do I validate a flag value?
Return an error from Set, or use flag.Func, which takes a
function returning an error. Both produce a usage message rather than a later panic.
How do I handle a parse error instead of exiting?
Create a FlagSet with
flag.ContinueOnError and check the error Parse returns. The package-level functions use
ExitOnError.
How do I implement subcommands?
One flag.FlagSet per command, switching on os.Args[1] and
parsing from os.Args[2:]. Beyond a few commands, a CLI library is less code.
What is the difference between os.Args and flag.Args()?
os.Args includes the program name at
index 0 and every argument. flag.Args() is only the positional arguments left after parsing.