Skip to content
CalliCoder

Reading Command Line Arguments in Go

Published Updated Golang 12 min read

os.Args includes the program name at index zero, why that name is not a reliable path, and the difference between raw arguments and what flag.Args returns after parsing.

os.Args is a []string and needs no package beyond os. The only thing to remember is that element zero is the program name rather than the first argument, which makes every naive length check off by one.

Written against Go 1.22.

The slice

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("program:", os.Args[0])
	fmt.Println("count:", len(os.Args)-1)

	for i, arg := range os.Args[1:] {
		fmt.Printf("  %d: %s\n", i+1, arg)
	}
}
go build -o app && ./app one two "three four"
# program: ./app
# count: 3
#   1: one
#   2: two
#   3: three four

"three four" is one argument. Quoting is the shell’s job: Go receives the already-split list and never sees the original command line, which is why there is no quoting or escaping to handle.

os.Args is never empty and never nil, so os.Args[0] is always safe. Every other index is not:

if len(os.Args) < 2 {
	fmt.Fprintf(os.Stderr, "usage: %s <input-file>\n", os.Args[0])
	os.Exit(2)
}
input := os.Args[1]

Exit code 2 is the convention for a usage error, distinguishing it from a program that ran and failed (1). Write the message to os.Stderr so that a caller piping stdout gets the data and the error separately.

os.Args[0] is not a path you can trust

It is whatever the caller passed as the program name, which is usually the path used to invoke it and is not guaranteed to be anything. Invoked through $PATH it is often just app; through a symlink it is the link’s name; and a caller using exec directly can set it to any string at all.

For a usage message that is fine and appropriate. Echoing back how the user invoked the program is what they expect to see.

For anything that needs the actual executable, use the right call:

exe, err := os.Executable()   // the real path, resolved
if err != nil {
	return err
}
dir := filepath.Dir(exe)

Reading a configuration file relative to the binary is the usual reason to need it. Note that os.Executable may still return a symlink; filepath.EvalSymlinks resolves it if that matters.

Using os.Args[0] for the program’s own name in log lines is also worth avoiding: filepath.Base of it at least strips the directory, but a hardcoded constant is more stable than either, and it survives a rename of the binary.

Writing to os.Args is legal Go and affects nothing outside the process; the kernel’s copy of the command line is unchanged, so it does not hide anything from ps.

Arguments against flags

flag.Parse()

os.Args      // ["./app", "-port", "9000", "input.txt"]
flag.Args()  // ["input.txt"]

Two different lists. os.Args is everything the process received, including the program name and every flag. flag.Args() is what is left after flag parsing consumed the options, with no program name.

Use flag.Args() in any program that has flags, and os.Args[1:] only in one that does not. Reading os.Args[1] as “the filename” in a program that also accepts flags gives you the first flag instead.

Validating and converting

Arguments are strings and arrive from outside, so both conversion and range need checking:

if len(os.Args) != 3 {
	fmt.Fprintf(os.Stderr, "usage: %s <count> <name>\n", filepath.Base(os.Args[0]))
	os.Exit(2)
}

count, err := strconv.Atoi(os.Args[1])
if err != nil {
	fmt.Fprintf(os.Stderr, "count must be a number: %v\n", err)
	os.Exit(2)
}
if count < 1 || count > 1000 {
	fmt.Fprintln(os.Stderr, "count must be between 1 and 1000")
	os.Exit(2)
}

strconv.Atoi reports the offending text in its error, so wrapping it rather than replacing it keeps the useful part.

A path argument needs more than a type check. It came from a user, so it can escape any directory you intended to confine it to:

base, _ := filepath.Abs("data")
target := filepath.Join(base, os.Args[1])

if !strings.HasPrefix(target, base+string(os.PathSeparator)) {
	return fmt.Errorf("path escapes the data directory")
}

filepath.Join cleans the result, which collapses .. segments, so the check has to come after the join, not before it.

Variable-length arguments

files := os.Args[1:]
if len(files) == 0 {
	files = []string{"-"}   // conventional: read stdin
}

for _, name := range files {
	if name == "-" {
		process(os.Stdin)
		continue
	}
	f, err := os.Open(name)
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		continue          // keep going through the rest
	}
	process(f)
	f.Close()
}

Two conventions worth following because users already expect them: - means stdin, and one bad file should not abort the remaining ones. Whether the exit status reflects a partial failure is a decision to make explicitly, most tools return non-zero if any input failed.

Shell globbing happens before the program starts, so app *.txt arrives as one argument per matching file. On Windows, where the shell does not expand globs, the program receives the literal *.txt and has to call filepath.Glob itself.

Arguments are visible to other processes

On Linux, a process’s arguments are readable from /proc/<pid>/cmdline by any user permitted to see the process, and ps prints them by default. That makes the command line the wrong place for a secret:

./app -password hunter2      # visible in ps output for the lifetime of the process

The exposure is not momentary: the arguments stay in the process table until it exits, and shell history keeps a copy afterwards. Overwriting the slice in Go does not help, because the kernel copy is what ps reads.

The alternatives, in rough order of preference: read the secret from a file whose path is the argument, read it from an environment variable, or read it from standard input. All three are described in environment variables, which has its own caveats, an environment variable is also readable from /proc, though only by the same user or root.

A useful convention is to accept both forms: -token-file for the path, and -token marked in the help text as insecure and intended for local development only.

Building the argument list for a child process

The reverse direction has one detail worth stating, because it is the opposite of what shell experience suggests:

cmd := exec.Command("grep", "-r", pattern, dir)

exec.Command takes the arguments as separate strings and passes them to the kernel directly. There is no shell, so there is no quoting, no globbing, and no injection. A pattern containing ; rm -rf / is one argument to grep and nothing else.

That safety disappears the moment a shell is introduced:

cmd := exec.Command("sh", "-c", "grep -r "+pattern+" "+dir)   // injectable

Reach for sh -c only when a shell feature is genuinely needed, and never with interpolated input.

Testing

os.Args is a package-level variable, which makes it awkward in tests. Take the slice as a parameter instead:

func run(args []string, stdout io.Writer) error {
	if len(args) != 2 {
		return fmt.Errorf("expected 2 arguments, got %d", len(args))
	}
	fmt.Fprintln(stdout, args[0])
	return nil
}

func main() {
	if err := run(os.Args[1:], os.Stdout); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

main becomes three lines that cannot be tested and do not need to be, and everything else takes its input as arguments. Passing the output writer in as well means a test can assert on what was printed without capturing global state.

Related: command-line flags and environment variables. More in the Golang guides.

Frequently asked questions

What is os.Args[0]?

The program name as the caller supplied it. Arguments start at index 1, so os.Args[1:] is the list most code wants.

Is os.Args ever empty?

No. It always has at least the program name, so index 0 is always safe and every other index needs a length check.

Can I trust os.Args[0] as the executable path?

No. It is whatever the caller passed and can be any string. Use os.Executable() for the real path.

How do I count the arguments?

len(os.Args) - 1, or len(os.Args[1:]). Forgetting the program name is the standard off-by-one here.

What is the difference between os.Args and flag.Args()?

os.Args is everything including the program name and the flags. flag.Args() is only what remains after parsing, with no program name.

How are quoted arguments handled?

By the shell, before the process starts. Go receives an already-split list, so "three four" arrives as a single element with no escaping to undo.

Does Go expand wildcards?

No, the shell does, on Unix. On Windows the program receives the literal pattern and must call filepath.Glob itself.

What exit code should a usage error return?

2 by convention, with the message on stderr. Reserve 1 for a program that ran and failed.

How do I safely use an argument as a file path?

filepath.Join it onto a base directory, then check the result still has that base as a prefix. Join cleans .., so the check must come after it.

How do I test argument handling?

Move the logic into a function taking []string and an io.Writer, and have main pass os.Args[1:] and os.Stdout. Reading the global directly makes the code untestable.