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.