How to Build a CLI Application in Go

Cli
Initialize a Go module, write a main function to handle arguments, and compile the binary to create a CLI application.

The single binary promise

You wrote a Python script to process logs. It works on your machine. You send it to a teammate. They need Python 3.9, the pandas library, and a specific virtual environment. You send them a Docker container. They need Docker. You want a single file they can download and run. That is the CLI promise. Go compiles to a single binary. No runtime. No dependencies to install. Just the file.

A CLI application in Go is a program that runs in the terminal. It reads arguments from the command line, does work, and writes output to standard out or standard error. The main package is the entry point. The main function is where execution starts. Go handles the heavy lifting of linking the standard library and producing an executable. You write the logic. The compiler handles the rest.

The smallest useful CLI

Here is the smallest CLI that does something useful: it greets a name passed as an argument.

package main

import (
	"fmt"
	"os"
)

// main is the entry point for the CLI application.
func main() {
	// os.Args contains the command-line arguments.
	// The first element is always the program name.
	if len(os.Args) > 1 {
		// Print a greeting using the first argument.
		fmt.Println("Hello,", os.Args[1])
	} else {
		// Fallback when no argument is provided.
		fmt.Println("Hello, World")
	}
}

Run go run main.go Alice. The output is Hello, Alice. The compiler compiles the code, links the standard library, and executes the binary in memory. os.Args is a slice of strings. os.Args[0] is main.go (or the binary name). os.Args[1] is Alice. If you run go run main.go, len(os.Args) is 1. The else branch runs. This is the raw interface. No flags library. No parsing. Just strings.

Arguments are strings. Parse them or panic.

Real tools handle flags and errors

Real tools handle errors, support flags, and exit with status codes. Here is a tool that copies a file with a verbose flag. The logic is split into a helper function and the main function.

The helper function performs the copy. It opens files, copies data, and returns errors wrapped with context.

package main

import (
	"fmt"
	"io"
	"os"
)

// copyFile reads from src and writes to dst.
// It returns an error if the copy fails.
func copyFile(src, dst string) error {
	// Open the source file for reading.
	sourceFile, err := os.Open(src)
	if err != nil {
		// Wrap the error with context about the operation.
		return fmt.Errorf("opening source: %w", err)
	}
	// Ensure the source file is closed when the function returns.
	defer sourceFile.Close()

	// Create the destination file.
	destFile, err := os.Create(dst)
	if err != nil {
		// Wrap the error with context about the operation.
		return fmt.Errorf("creating destination: %w", err)
	}
	// Ensure the destination file is closed when the function returns.
	defer destFile.Close()

	// Copy data from source to destination.
	// io.Copy handles buffering and chunking automatically.
	// Discard the byte count since we only care about errors.
	_, err = io.Copy(destFile, sourceFile)
	if err != nil {
		return fmt.Errorf("copying data: %w", err)
	}

	return nil
}

The main function parses flags and orchestrates the call. It uses the standard flag package.

package main

import (
	"flag"
	"fmt"
	"os"
)

// main parses flags and executes the copy operation.
func main() {
	// Define a boolean flag for verbose output.
	// The default value is false. The usage string appears in help.
	verbose := flag.Bool("v", false, "enable verbose logging")
	// Parse the command-line flags.
	// This must be called before accessing flag values.
	flag.Parse()

	// flag.Args() returns the non-flag arguments.
	args := flag.Args()
	if len(args) != 2 {
		// Print usage information to stderr and exit with error code.
		fmt.Fprintln(os.Stderr, "Usage: cp-go <src> <dst>")
		flag.PrintDefaults()
		os.Exit(1)
	}

	src := args[0]
	dst := args[1]

	if *verbose {
		fmt.Printf("Copying %s to %s\n", src, dst)
	}

	// Execute the copy and handle errors.
	if err := copyFile(src, dst); err != nil {
		// Print the error to stderr and exit with non-zero status.
		fmt.Fprintf(os.Stderr, "Error: %v\n", err)
		os.Exit(1)
	}

	if *verbose {
		fmt.Println("Copy complete")
	}
}

Flags make tools usable. Errors make tools trustworthy.

Modules and building

Go uses modules to manage dependencies and define the module path. Initialize a module before building.

# Initialize the module.
# go mod init sets up the module path and creates go.mod.
go mod init cp-go

# Build the binary.
# go build compiles the package and links the executable.
# The -o flag specifies the output file name.
go build -o cp-go

# Run the binary.
./cp-go -v source.txt dest.txt

The go.mod file tracks the module name and dependencies. If you import external packages, go mod tidy updates the file. The go build command produces a binary in the current directory. The binary name defaults to the directory name unless -o is specified. Go caches build artifacts. Subsequent builds are fast because the compiler reuses compiled packages.

The gofmt tool formats code automatically. Most editors run it on save. Do not argue about indentation. Let the tool decide. Consistent formatting reduces cognitive load when reading code.

Cross-compilation

Go compiles to native code for many platforms. You can build a macOS binary on Linux. You can build a Windows binary on a Raspberry Pi. Set the GOOS and GOARCH environment variables to change the target.

# Build for Windows on any machine.
# GOOS sets the target operating system.
# GOARCH sets the target architecture.
GOOS=windows GOARCH=amd64 go build -o cp-go.exe

# Build for Linux ARM (like a Raspberry Pi).
GOOS=linux GOARCH=arm64 go build -o cp-go-arm64

This works because the Go toolchain includes compilers for all supported platforms. No cross-compilation toolchains or foreign headers are needed. The standard library is built for the target platform automatically.

One compiler. Every platform. No cross-compilation headaches.

Pitfalls and compiler errors

Accessing os.Args[1] without checking the length causes a panic. The runtime stops with runtime error: index out of range [1] with length 1. Always check bounds before indexing.

Forgetting to call flag.Parse() is a common mistake. The compiler does not catch this. The flags just won't work. The program reads raw arguments instead of parsed values. Call flag.Parse() early in main.

Using %w in fmt.Errorf wraps errors. This allows callers to check the error with errors.Is or errors.As. Using %v loses the error chain. Wrap errors so the root cause is visible.

The compiler rejects unused imports with imported and not used. If you import flag but do not use it, the build fails. Remove unused imports. The compiler rejects undefined variables with undefined: pkg. Check spelling and imports.

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Do not hide errors. Return them or handle them explicitly.

The compiler catches syntax. You catch logic. Check bounds before indexing.

When to use what

Use os.Args for tiny scripts where you parse strings manually. Use the standard flag package for simple tools with a few boolean or string options. Use a library like cobra when you need subcommands, auto-generated help, and a complex hierarchy. Use go run for quick testing during development. Use go build to produce a distributable binary. Use go install to place binaries in your GOPATH/bin for global access.

Start simple. Add complexity only when the tool demands it.

Where to go next