Skip to content
gohacks.dev
Basics

Command Line Arguments in Go Without Any Library at All

Read command line arguments with os.Args, validate the count, convert types, print usage to stderr and exit with the right code. See what the shell passes in.

6 min read
Summarize in AI
Flat illustration of a terminal session.

Before you reach for a command line framework, it is worth knowing that a Go program already has its arguments in a variable, and that for a small tool nothing else is needed. os.Args is a slice of strings, and the interesting problems are all in how you check it, convert it and fail when it is wrong. This how-to builds a small tool that takes a name and a count, shows exactly what the shell hands your program, covers the exit codes that make a tool scriptable, and says where to stop and use the flag package instead. It belongs in the Go basics path, follows reading user input and leads into environment variables.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

What os.Args holds

The first element is the name the program was started with. The rest are the arguments, in order, as separate strings. The shell has already done the splitting: spaces separate arguments unless quoted, quotes are removed, and *.txt has been expanded to file names on Unix. Your program sees the result:

main.goGo
package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	args := os.Args[1:]
	fmt.Println("argument count:", len(args))
	for i, a := range args {
		fmt.Printf("args[%d] = %q\n", i, a)
	}

	if len(args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: app <name> <count>")
		os.Exit(2)
	}

	count, err := strconv.Atoi(args[1])
	if err != nil || count < 1 {
		fmt.Fprintf(os.Stderr, "app: count must be a positive integer, got %q\n", args[1])
		os.Exit(2)
	}
	for range count {
		fmt.Println("hello,", args[0])
	}
}

Run it with three arguments, one of them quoted so that it contains a space:

Shell
go run . ada 2 "extra words"
Text
argument count: 3
args[0] = "ada"
args[1] = "2"
args[2] = "extra words"
hello, ada
hello, ada

The quoted text arrived as one argument, "extra words", and the count of three includes it. Now give it too few, and then a bad count. go run reports the exit status after the program's own messages, and I redirected standard error into the output so you see both:

Shell
go run . ada 2>&1; echo "---"; go run . ada nine 2>&1; true
Text
argument count: 1
args[0] = "ada"
usage: app <name> <count>
exit status 2
---
argument count: 2
args[0] = "ada"
args[1] = "nine"
app: count must be a positive integer, got "nine"
exit status 2

The first run printed the usage line and stopped. The second printed the error that names the offending value in quotes, which is the habit that makes a message useful: the user can see exactly what the program received. Note that go run adds an exit status 2 line of its own, which is not from your program.

Check the length before you index

The most common crash in a first command line tool is os.Args[1] when nobody gave an argument. The panic is index out of range [1] with length 1, and it comes with a stack trace instead of a message a user can act on. Compare the length first, as the example does, or take the arguments with a helper that returns an error:

Go
func arg(i int) (string, error) {
	if i >= len(os.Args) {
		return "", fmt.Errorf("missing argument %d", i)
	}
	return os.Args[i], nil
}

For a tool with a required first argument and an optional second, slicing is clearer: args := os.Args[1:], then len(args) is the count the user typed, and the program name no longer gets in the way of the arithmetic.

What os.Args[0] is

It is whatever name the program was invoked by. For a built binary, started as ./app, it is ./app. For go run, which builds into a temporary directory and runs from there, it is a long path under the temp folder that changes every time. Do not print it in messages or use it to find your own files. If you need the program's name for a usage line, hard-code it, or use filepath.Base(os.Args[0]), which gives the file name only. If you need the directory of the running executable, os.Executable() returns the path, though for most programs the right answer is a flag or an environment variable instead.

Exit codes and where messages go

Two conventions make a command line tool pleasant to script. Error messages and usage go to standard error, so that app > out.txt does not put them in the file. And the exit status says what happened: 0 for success, 2 for a usage error (the convention from the flag package and many Unix tools), 1 for any other failure. A shell script or a CI step can then react without parsing text:

Shell
app ada 3 || echo "failed with status $?"

os.Exit ends the process at once. It does not run deferred functions, so anything that must happen before the exit, flushing a buffered writer or closing a file you wrote, has to be called explicitly first. The pattern that keeps main honest is to put the work in a run(args []string) error function, which can use defer freely and returns an error, and to let main print the error and call os.Exit(1) once. Defer and the order things run in shows why.

Subcommands by hand

A tool with several modes, in the style of git commit, can dispatch on the first argument with a switch:

Go
switch os.Args[1] {
case "add":
	err = runAdd(os.Args[2:])
case "list":
	err = runList(os.Args[2:])
default:
	fmt.Fprintf(os.Stderr, "unknown command %q\n", os.Args[1])
	os.Exit(2)
}

That works for two or three commands and stops scaling once each command wants its own flags, help text and shell completion. Frameworks such as Cobra exist for that case, and the standard flag package supports a FlagSet per subcommand if you want to stay with the standard library.

Testing the argument handling

A main that reads os.Args directly cannot be tested without starting a process. Pass the arguments in instead, and the test supplies a slice:

Go
func run(args []string, stdout, stderr io.Writer) int {
	if len(args) < 2 {
		fmt.Fprintln(stderr, "usage: app <name> <count>")
		return 2
	}
	// ... do the work, writing to stdout
	return 0
}

func main() {
	os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}

The function returns the exit code and writes to writers you control, so a test can call run([]string{"ada"}, &out, &errOut) and assert on the code and both buffers. main shrinks to one line that no test needs. It is the same move as taking an io.Reader for input, and the combination makes the whole tool testable.

When os.Args is not enough

Hand parsing breaks down as soon as you want flags: -v, --port=8080, -n 5, values with defaults and a generated -h message. The flag package handles those with a few lines, including the usage text and the exit code 2 on bad input, and it should be your first stop for anything with options. Do not write a flag parser by looping over os.Args, because the corner cases, -- to end flags, -n5 against -n 5, repeated flags, are the part you will get wrong. Tools with many options and subcommands belong on Cobra, which wraps the same ideas with structure, and for a TUI the arguments are only the starting point. Also remember that arguments are visible to every user on the machine through the process list, so secrets belong in environment variables or files with restricted permissions, not on the command line. The standard references are the os package documentation for Args and the flag package.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.