CLI tool
alexflint/go-arg avatar
alexflint/go-arg

go-arg turns a Go struct into a command line interface, tags and all

Struct-based argument parsing in Go

2,288 stars120 forksGoBSD-2-Clause

At a glance

What is it?
go-arg parses command line arguments by reflecting over a struct and its arg, help and default tags, and it resolves values in a fixed order from flags, environment variables and defaults. It suits Go programs that want a generated usage string without hand written parsing.
Who is it for?
Adopt go-arg when a Go CLI has a modest, flat flag set and you want the usage text to stay in sync with the struct by construction; Before committing, run your own binary with a missing required flag to see the exact error text your users will get, and check that the go.mod line of go 1.18 matches the oldest toolchain in your build matrix.
Can I use it commercially?
Yes. BSD-2-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 4 days ago.
What is it written in?
Mainly Go, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The struct is the schema, so usage text cannot drift from it

Every Go argument parser asks you to declare flags and then to describe them again when you build the help screen. go-arg removes the second declaration by making the struct the single source. Field names become option names, struct tags rename them, and the usage string is rendered from the same reflection pass that does the parsing. There is no separate description object to keep in sync.

The minimal program is four lines of interest. Declare the fields, call arg.MustParse, then read them:

go
var args struct {
	Foo string
	Bar bool
}
arg.MustParse(&args)
fmt.Println(args.Foo, args.Bar)

Running that binary with --foo=hello --bar prints hello true. The README gives the output rather than describing it, which is unusual and useful: the flag names in the usage output can be read straight off the example instead of inferred from naming conventions.

For anyone who has maintained a CLI, the appeal is not brevity but drift. Hand written usage strings go stale silently, and the only symptom is a user passing a flag that no longer exists. go-arg cannot produce that state, at the cost of giving up the free form layout that hand written help allows.

Precedence is a fixed three step chain, not a merge

Values resolve in one order: command line arguments first, then environment variables when an env tag is present, then the default tag. There is no merging of partial values, and no per-source tracking once a value lands in the struct. If a slice field can come from either a flag or an environment variable, the flag wins outright and the variable is not consulted.

The chain is visible in small examples. With a plain env tag, WORKERS=4 ./example prints Workers: 4, and adding --workers=6 to the same command prints 6, showing the flag overriding the variable. A slice bound to the environment splits on commas, so WORKERS='1,99' yields [1 99].

Two knobs in arg.Config let a caller step out of the chain. IgnoreEnv: true drops environment variables entirely, IgnoreDefault: true drops default values, and both can be set together before p.Parse. The name is plural for both, which matters when a struct mixes tagged and untagged fields: the switch is global to the parser, not per field.

A global EnvPrefix can be set the same way, so MYAPP_NUM_WORKERS=6 satisfies a field tagged env:NUM_WORKERS when Config carries EnvPrefix: "MYAPP_". That is the mechanism most likely to be needed by anyone embedding the library in a larger binary that already owns the environment.

The tag vocabulary covers renaming, positionals, repeats and maps

The arg tag is a comma separated list of sub-tags, and each one changes a specific rule of the parser. Renaming is the simplest: arg:"-s" sets a short name, arg:"--custom-long-option" replaces the derived long name, arg:"-x,--my-option" sets both, and arg:"-o,--" marks a field as short only so that no long form is generated.

Positionals are marked with arg:"positional" and follow a strict shape: one scalar, then any number of slice values, or as the README example puts it, a single required string followed by a repeated one. A slice option that is not positional and not marked separate consumes the rest of the command line, which is why -database foo -ids 1 2 3 assigns [1 2 3] to IDs and leaves nothing afterwards.

Repeatable options use separate. With Commands and Files both marked separate and a positional Databases, the mixed invocation -c cmd1 db1 -f file1 db2 -c cmd2 -f file2 -f file3 db3 -c cmd3 collects each group into its own slice and leaves [db1 db2 db3] for the positional, interleaved as typed. map[string]int fields become key=value pairs, so --userids john=123 mary=456 fills the map.

Defaults and help text live in separate tags, default:"abc" and help:"verbosity level", which render into the Options block of the generated usage.

Required flags fail with a usage line, not a stack trace

Marking a field arg:"required" makes its absence an error. The documented output for a struct with a required ID and an optional Timeout is a usage line followed by the error:

code
$ ./example
Usage: example --id ID [--timeout TIMEOUT]
error: --id is required

That two line shape matters for scripts. The message is on stderr in a predictable format and the usage line is regenerated from the struct, so a user who omits a flag sees the exact spelling of the flag they should have used. Required positionals show up as bare names in a Positional arguments block, while options appear with their type placeholders in an Options block, sorted rather than declared in struct order.

Custom validation rides on the same path. arg.MustParse returns a parser value, and calling p.Fail with your own message produces the usage line plus your text:

go
p := arg.MustParse(&args)
if args.Foo == "" && args.Bar == "" {
	p.Fail("you must provide either --foo or --bar")
}

This is the documented way to express constraints that a type cannot, such as requiring at least one of two mutually compatible options. The README does not document returning a non-zero exit status from a custom check, so a program that wants a specific code must handle that itself.

Version strings come from a method, and a declared flag wins

Rather than a Version tag, go-arg looks for a method on the args type. If the struct passed to MustParse has a Version() string method, --version prints its return value. The README's example returns something like someprogram 4.3.0, which means the version string is assembled in application code rather than read from a build variable, leaving the linker story to the caller.

The collision rule is explicit and worth remembering: if a --version flag is declared in args or in any subcommand, that declaration overrides the built-in versioning. Silent override is a trap for anyone renaming fields by tag, since a field called Version in the struct will take over the built in output.

The same override logic applies to --help, which the generated usage always lists as --help, -h. The README does not explain how to disable the help flag or rename it, so a program that needs a different convention for it has no documented path.

What the source tree says about scale and about limits

The repository is a single package of focused files, and their names map to the features above: reflect.go holds the tag reading, parse.go the flag handling, usage.go the help rendering, sequence.go the slice handling, subcommand.go the nested command support, and register.go the option registry. Tests sit next to each file as parse_test.go, usage_test.go and so on, with example_test.go at the root for the documented snippets.

go.mod requires github.com/alexflint/go-scalar and github.com/stretchr/testify, with go-spew, go-difflib and yaml.v3 listed as indirect dependencies. The module declares Go 1.18.

Licensing is BSD 2-Clause with the text in LICENSE. For a company embedding this in a shipped binary that clause set is permissive and carries no copyleft obligation. The README does not document a stability policy for the tag vocabulary, so a project depending on a rarely used sub-tag has no stated compatibility guarantee to point at.

Where the standard flag package and cobra draw different lines

The closest built-in comparison is the flag package in the Go standard library, and the difference is generation versus registration. The standard library requires a FlagSet call per option and a hand written PrintDefaults, which means the help output is a separate maintenance task. go-arg derives both from one struct, at the price of accepting that the struct tags are now a public interface of your program.

cobra sits at the other end of the design space. It builds a command tree, supports persistent flags inherited by subcommands, generates shell completion, and integrates with Viper for configuration files. go-arg has subcommand support in subcommand.go, and the README's embedded structs section is where flags are shared across levels, but the README does not describe completion scripts or configuration file loading. A tool with nested commands and inherited flags is served better by a tree oriented framework.

The practical dividing line is size. A tool with under a dozen flags and one set of positionals gains the most from go-arg, because the struct stays readable and the generated help is complete. A tool with dozens of options, nested verbs and config file precedence has outgrown what a flat struct can express.

Editorial conclusion

Adopt go-arg when a Go CLI has a modest, flat flag set and you want the usage text to stay in sync with the struct by construction; Before committing, run your own binary with a missing required flag to see the exact error text your users will get, and check that the go.mod line of go 1.18 matches the oldest toolchain in your build matrix.

Frequently asked questions

How do I install go-arg in a Go project?

The README gives a single command, go get github.com/alexflint/go-arg, and the module is published under that path on pkg.go.dev.

Does a go-arg command line flag override the environment variable?

Yes. Command line arguments take precedence over environment variables, and environment variables take precedence over default values, in that fixed order.

How do I make a flag required in go-arg?

Add the arg:"required" tag to the field. Parsing then stops with a usage line and an error reading, for example, error: --id is required.

How do I turn off environment variable and default value handling in go-arg?

Build the parser with arg.NewParser and an arg.Config that sets IgnoreEnv: true or IgnoreDefault: true, then call p.Parse instead of arg.MustParse.

How does go-arg produce a version string?

Give the args type a Version() string method and pass it to arg.MustParse, so --version prints whatever that method returns. A --version flag declared in args overrides this built in behaviour.

Official sources

  1. alexflint/go-arg on GitHub
  2. License: BSD-2-Clause
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/alexflint-go-arg.svg)](https://hysenlabs.com/projects/alexflint-go-arg)

Was this useful?