Go CLI Basics
Learning Objectives
By the end of this page, you will be able to:
- Explain why Go is a natural fit for building command-line tools.
- Read command-line arguments using
os.Argsand parse flags using theflagpackage. - Build a small CLI program that accepts both positional arguments and named flags.
- Structure a multi-command CLI using subcommands, either manually or with the Cobra library.
- Identify common mistakes beginners make when parsing flags and arguments in Go.
- Compare Go's built-in
flagpackage with third-party libraries like Cobra andurfave/cli.
Quick Answer
A Go CLI (command-line interface) is a program compiled into a single, dependency-free binary that reads input from the terminal — either as positional arguments (os.Args) or named flags (the flag package) — and prints output or performs an action. Go is popular for CLI tools because it compiles to a standalone binary with no runtime to install, starts instantly, and cross-compiles easily for Linux, macOS, and Windows from one machine. Tools like Docker, Kubernetes's kubectl, Terraform, and Hugo are all Go CLIs. For anything beyond a handful of flags, most real-world Go CLIs move from the standard flag package to a library like Cobra, which adds subcommands, help text, and shell autocompletion.
Why Go Dominates the CLI Tooling Space
Think about what happens when you install a CLI tool written in Python versus one written in Go. The Python tool needs an interpreter, the right version of that interpreter, and often a virtual environment with dependencies installed. The Go tool is a single binary — you download it, chmod +x, and run it. That difference alone explains why so much of the modern developer-tools ecosystem (Docker, Kubernetes, Terraform, GitHub CLI, Hugo) is written in Go: go build produces one statically linked executable per platform, with zero external runtime requirements.
Go also starts fast. A CLI tool is invoked over and over, often inside scripts or CI pipelines, so a JVM-style multi-second startup cost would be painful. Go binaries start in milliseconds because there's no interpreter to boot and no JIT warm-up.
Setting Up a Go CLI Application
Every Go CLI starts the same way: a module and a main package.
mkdir my-cli-app
cd my-cli-app
go mod init my-cli-app
go mod init creates a go.mod file that names your module and pins the Go version — this is what lets go build resolve dependencies deterministically.
Your first program, reading raw positional arguments:
package main
import (
"fmt"
"os"
)
func main() {
fmt.Println("Welcome to My CLI Application!")
if len(os.Args) > 1 {
fmt.Println("Arguments passed:", os.Args[1:])
} else {
fmt.Println("No arguments provided.")
}
}
os.Args is a []string where os.Args[0] is always the path to the binary itself — that's why the example slices from index 1.
go run main.go arg1 arg2
Welcome to My CLI Application!
Arguments passed: [arg1 arg2]
Real-world example: this is exactly the pattern cp source.txt dest.txt uses under the hood — two positional arguments, no flags, and the program branches on len(os.Args).
Why it matters: raw os.Args handling is fine for a one-off script, but it doesn't validate types, doesn't generate --help text, and forces you to write your own parsing loop the moment you need an optional flag like -verbose. That's where the flag package comes in.
Common misunderstanding: beginners often assume os.Args[0] is the first argument the user typed. It isn't — it's the program's own path. Forgetting to skip it causes off-by-one bugs when printing or counting arguments.
Handling Command-Line Flags
The standard library's flag package parses -name=value (or -name value) style flags without any third-party dependency.
package main
import (
"flag"
"fmt"
)
func main() {
name := flag.String("name", "User", "Your name")
verbose := flag.Bool("verbose", false, "Enable verbose output")
flag.Parse()
fmt.Printf("Welcome to My CLI Application, %s!\n", *name)
if *verbose {
fmt.Println("Verbose mode is on.")
}
if len(flag.Args()) > 0 {
fmt.Println("Additional arguments:", flag.Args())
}
}
go run main.go -name=John -verbose arg1 arg2
Welcome to My CLI Application, John!
Verbose mode is on.
Additional arguments: [arg1 arg2]
Two details trip people up here. First, flag.String, flag.Bool, and friends return pointers (*string, *bool), so you must dereference them with *name to read the value. Second, flag.Parse() must run before you read any flag value, and all flags must be defined before Parse() is called — flags placed after positional arguments on the command line are not parsed by the standard library (see Common Mistakes below).
Why it matters: the flag package gives you free -h/--help output, type-checked values (a non-numeric string passed to flag.Int produces a clear error), and default values — all without adding a dependency, which keeps your binary small and your go.mod simple.
Structuring Larger CLIs with Subcommands
Real tools like git commit, docker run, and kubectl get pods aren't single-command programs — they're a tree of subcommands, each with its own flags. The standard library doesn't provide this out of the box, so most production Go CLIs use Cobra, the library behind kubectl, Hugo, and the GitHub CLI.
go get github.com/spf13/cobra@latest
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "My CLI Application",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Welcome to My CLI Application!")
},
}
var greetCmd = &cobra.Command{
Use: "greet [name]",
Short: "Greet someone by name",
Args: cobra.MinimumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Hello, %s!\n", args[0])
},
}
func Execute() {
rootCmd.AddCommand(greetCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
// main.go
package main
import "my-cli-app/cmd"
func main() {
cmd.Execute()
}
Running mycli greet Alice now dispatches to the greetCmd handler, and Cobra automatically wires up mycli --help, mycli greet --help, and (with an extra one-line setup) shell autocompletion for bash, zsh, and fish.
Real-world example: kubectl get pods --namespace=default is a Cobra command tree — kubectl is the root, get is a subcommand, pods is a positional argument to get, and --namespace is a flag scoped to that subcommand.
Mermaid Diagram: How a Go CLI Parses Input
Key Terms
| Term | Definition |
|---|---|
os.Args | A []string slice containing all command-line arguments, with index 0 being the program's own path. |
flag package | Go's standard library package for defining and parsing named command-line flags like -name=value. |
| Positional argument | A value passed by position rather than by name, e.g., the file.txt in cat file.txt. |
| Flag | A named option, usually prefixed with - or --, that configures a program's behavior (e.g., -verbose). |
| Subcommand | A named action nested under a root command, such as get in kubectl get pods. |
| Cobra | A popular third-party Go library for building CLIs with subcommands, flags, and auto-generated help. |
go mod init | The command that creates a go.mod file, declaring a Go module and its dependency requirements. |
| Exit code | The integer a program returns to the shell on exit; 0 means success, non-zero signals an error. |
Common Mistakes
Misconception 1: "Flags can go anywhere on the command line, before or after positional arguments."
Why it's wrong: Go's standard flag package stops parsing flags as soon as it encounters the first non-flag argument. Anything after that is treated as a positional argument, even if it looks like a flag.
Correct understanding: Always place flags before positional arguments when using the standard flag package — e.g., mycli -name=John arg1, not mycli arg1 -name=John. Libraries like Cobra relax this restriction, which is one reason larger CLIs adopt them.
Misconception 2: "flag.String("name", "User", "...") returns the string value directly."
Why it's wrong: it returns a *string pointer, because the value isn't populated until flag.Parse() runs later in main(). Treating it as a string before dereferencing causes a compile error, and forgetting to dereference it (*name) after parsing causes confusing type mismatches.
Correct understanding: store the pointer, call flag.Parse(), then dereference with *name wherever you need the value.
Misconception 3: "A CLI written in Go doesn't need error handling because the compiler catches everything."
Why it's wrong: Go's compiler only catches type and syntax errors — it can't catch a missing file, an invalid flag value at runtime, or a network timeout. CLI tools that silently ignore runtime errors leave users staring at blank output with no idea what went wrong.
Correct understanding: check every error return value (Go's if err != nil idiom), print a clear message to os.Stderr, and call os.Exit(1) on failure so shell scripts calling your tool can detect the failure via the exit code.
Comparison and Connections
| Approach | Best For | Subcommands? | Auto Help/Completion | Dependency |
|---|---|---|---|---|
os.Args | Tiny scripts, 1-2 positional args | No | No | None |
flag package | Small tools with a handful of flags | No (manual only) | Basic -h | None (standard library) |
| Cobra | Production CLIs with nested commands | Yes | Yes, including shell completion | github.com/spf13/cobra |
urfave/cli | Simpler alternative to Cobra | Yes | Yes | github.com/urfave/cli |
Practice Questions
Recall
- What does
os.Args[0]contain, and why is that important when counting arguments? Answer guidance: It contains the path to the running binary, not user input, so argument processing typically starts at index 1. - Which standard library package parses
-flagname=valuestyle command-line options in Go? Answer guidance: Theflagpackage.
Understanding
- Explain why
flag.String()returns a pointer instead of a plain string. Answer guidance: The value doesn't exist untilflag.Parse()runs later; a pointer lets the flag package fill in the value after the call returns, without requiring the caller to pass a variable by reference manually. - Why does Go produce a single self-contained binary while a Python CLI tool typically requires an interpreter and installed packages?
Answer guidance: Go is a statically compiled language —
go buildlinks the runtime and all dependencies into one executable. Python is interpreted, so it needs a matching interpreter and installed libraries present on the target machine at run time.
Application
- Write the flag definition and parsing code needed for a CLI that accepts an integer flag
-countdefaulting to1. Answer guidance:count := flag.Int("count", 1, "number of times to repeat"); flag.Parse(), then use*count. - A user runs
mycli arg1 -verboseand reports that verbose mode isn't turning on. Diagnose the likely cause. Answer guidance: With the standardflagpackage, flag parsing stops at the first non-flag token, so-verboseafterarg1is treated as a positional argument, not a flag. The fix is to place flags first or switch to a library like Cobra that supports flags after subcommands.
Analysis
- Compare using the standard
flagpackage versus adopting Cobra for a CLI that will eventually need 10+ subcommands. What trade-offs are involved? Answer guidance:flagkeeps the binary dependency-free and is fine for simple tools, but manually routing 10+ subcommands means hand-writing dispatch logic, help text, and argument validation for each. Cobra adds one external dependency but provides automatic help generation, consistent subcommand structure, and shell completion — worth it once the command tree grows past a few commands. - Why might a company like Docker or HashiCorp (Terraform) choose Go specifically for their CLI tools rather than a language like Java or Node.js? Answer guidance: Go produces single, dependency-free, fast-starting binaries that cross-compile easily for every OS/architecture combination — critical for tools distributed to thousands of developer machines and CI runners where installing a JVM or Node runtime first would be extra friction.
FAQ
Do I need a third-party library to build a Go CLI?
No. The standard library's flag package and os.Args are enough for simple tools. Reach for Cobra or urfave/cli once you need subcommands, auto-generated help, or shell completion.
Why does my flag value show as a memory address instead of the value I set?
You likely printed the pointer instead of dereferencing it — printing name instead of *name after flag.Parse().
Can I mix positional arguments and flags with the standard flag package?
Yes, but flags must come before the first positional argument; parsing stops at the first non-flag token. Libraries like Cobra handle this more flexibly.
How do I make my Go CLI available for Windows, macOS, and Linux?
Cross-compile using GOOS and GOARCH environment variables, e.g., GOOS=windows GOARCH=amd64 go build — no separate build machine needed.
What's the difference between flag.Parse() failing and my program panicking?
flag.Parse() handles invalid flag input gracefully — it prints usage and calls os.Exit(2) by default. A panic is an unrecovered runtime error in your own code (e.g., a nil pointer dereference), which is a bug you should fix, not something users should ever trigger.
Quick Revision
os.Argsis a[]string; index 0 is the program path, real arguments start at index 1.- The
flagpackage parses named flags like-name=value; values come back as pointers. - Always call
flag.Parse()before reading flag values, and define flags before that call. - Standard
flagparsing stops at the first positional (non-flag) argument — order matters. flag.Args()returns the leftover positional arguments after flags are parsed.- Cobra is the standard library for production Go CLIs needing subcommands (used by
kubectl, Hugo, GitHub CLI). - Go compiles to a single static binary — no interpreter or runtime needed on the target machine.
- Cross-compilation is built in via
GOOS/GOARCHenvironment variables. - Always check and handle errors explicitly; Go has no automatic exception propagation.
- Exit code
0means success; non-zero signals failure to any calling shell script.
Related Topics
Prerequisites: Go syntax basics (functions, packages, imports), understanding of the terminal/shell, basic error handling in Go (if err != nil).
Related Topics: History of Go, Go Use Cases and Advantages, Go's os and io packages, environment variables in Go.
Next Topics: Building REST APIs in Go, Go concurrency with goroutines and channels, packaging and distributing Go binaries (GoReleaser).