Skip to content
CalliCoder

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.