Writing CLI Tools in Go
TL;DR
Go makes great CLIs: single binary, fast startup, easy cross-compile. Use cobra or flag package for arguments, os.Exit for error codes, and goroutines for parallelism. Structure for testability from the start.
I've written CLI tools in Python, Ruby, Node, and Go. I reach for Go every time now. The main reason: I hand the binary to someone and it works. No runtime, no version conflicts, no pip install. One file.
Here's the structure I use for every Go CLI.
Why Go for CLIs
Python: great stdlib, terrible distribution
→ "Run pip install -r requirements.txt"
→ "Which python3? I have 3.8 and 3.11"
→ Bundle with PyInstaller for 80MB binary
Node: fast to write, painful to ship
→ node_modules is 200MB
→ pkg/nexe for bundling, but still awkward
Go:
go build -o mytool .
# Ship the binary. Done.
# Cross-compile: GOOS=linux GOARCH=amd64 go build
# Startup: <10ms
# Size: 5-15MB (or smaller with -ldflags="-s -w")
The Minimal Structure
mycli/
├── main.go
├── cmd/
│ ├── root.go
│ ├── list.go
│ └── create.go
└── internal/
└── client/
└── client.go
// main.go — entry point, nothing else
package main
import (
"os"
"mycli/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
Using Cobra for Subcommands
Cobra is the de facto standard for multi-command CLIs (kubectl, gh, docker all use it):
// cmd/root.go
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "mycli",
Short: "My CLI tool",
Long: "A longer description of what mycli does.",
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
// Global flags
rootCmd.PersistentFlags().String("config", "", "Config file path")
rootCmd.PersistentFlags().Bool("verbose", false, "Verbose output")
}
// cmd/list.go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var listCmd = &cobra.Command{
Use: "list",
Short: "List all items",
RunE: runList, // RunE returns an error; cobra handles it
}
func init() {
rootCmd.AddCommand(listCmd)
listCmd.Flags().StringP("format", "f", "text", "Output format: text|json|csv")
listCmd.Flags().IntP("limit", "n", 20, "Max items to show")
}
func runList(cmd *cobra.Command, args []string) error {
format, _ := cmd.Flags().GetString("format")
limit, _ := cmd.Flags().GetInt("limit")
items, err := fetchItems(limit)
if err != nil {
return fmt.Errorf("fetch failed: %w", err)
}
return printItems(items, format)
}
Exit Codes and Error Handling
// Good: differentiate errors for scripts to react to
const (
ExitOK = 0
ExitError = 1
ExitUsage = 2
)
// With cobra: RunE returning an error exits with code 1
// For custom exit codes, use os.Exit directly
func runDelete(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return fmt.Errorf("requires at least 1 argument")
// cobra prints usage and exits 1
}
if err := deleteItem(args[0]); err != nil {
// Don't fmt.Printf here — just return the error
// cobra handles printing it
return fmt.Errorf("delete %q: %w", args[0], err)
}
fmt.Printf("Deleted %s\n", args[0])
return nil
}
stdin/stdout/stderr
// Write output to stdout
fmt.Println("result")
// Write errors/logs to stderr (don't pollute stdout)
fmt.Fprintln(os.Stderr, "warning: something happened")
// Read from stdin (pipe support)
func readStdin() ([]string, error) {
var lines []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, scanner.Err()
}
// Detect if stdin is a pipe or terminal
func isaPipe() bool {
stat, _ := os.Stdin.Stat()
return (stat.Mode() & os.ModeCharDevice) == 0
}
func runProcess(cmd *cobra.Command, args []string) error {
var items []string
if isaPipe() {
// cat items.txt | mycli process
items, _ = readStdin()
} else {
// mycli process item1 item2
items = args
}
// Process items...
return nil
}
Pretty Output
// Respect NO_COLOR env var (https://no-color.org)
var useColor = os.Getenv("NO_COLOR") == "" && isTerminal(os.Stdout)
func isTerminal(f *os.File) bool {
stat, _ := f.Stat()
return (stat.Mode() & os.ModeCharDevice) != 0
}
// Simple color without a library
const (
colorRed = "\033[31m"
colorGreen = "\033[32m"
colorReset = "\033[0m"
)
func success(msg string) {
if useColor {
fmt.Printf("%s✓ %s%s\n", colorGreen, msg, colorReset)
} else {
fmt.Printf("✓ %s\n", msg)
}
}
func failure(msg string) {
if useColor {
fmt.Fprintf(os.Stderr, "%s✗ %s%s\n", colorRed, msg, colorReset)
} else {
fmt.Fprintf(os.Stderr, "✗ %s\n", msg)
}
}
For tables, the text/tabwriter package from stdlib is enough:
func printTable(items []Item) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "NAME\tSTATUS\tCREATED")
for _, item := range items {
fmt.Fprintf(w, "%s\t%s\t%s\n",
item.Name,
item.Status,
item.CreatedAt.Format("2006-01-02"),
)
}
w.Flush()
}
Config Files
import (
"encoding/json"
"os"
"path/filepath"
)
type Config struct {
APIKey string `json:"api_key"`
Endpoint string `json:"endpoint"`
}
func loadConfig() (*Config, error) {
// Check XDG_CONFIG_HOME first, then ~/.config/mycli/config.json
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home, _ := os.UserHomeDir()
configDir = filepath.Join(home, ".config")
}
path := filepath.Join(configDir, "mycli", "config.json")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil // empty config is fine
}
return nil, err
}
var cfg Config
return &cfg, json.Unmarshal(data, &cfg)
}
func saveConfig(cfg *Config) error {
home, _ := os.UserHomeDir()
dir := filepath.Join(home, ".config", "mycli")
os.MkdirAll(dir, 0700)
data, _ := json.MarshalIndent(cfg, "", " ")
return os.WriteFile(filepath.Join(dir, "config.json"), data, 0600)
}
Parallelism
// Process a list of items concurrently
func processAll(items []string) error {
const workers = 10
jobs := make(chan string, len(items))
errs := make(chan error, len(items))
// Start workers
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
for item := range jobs {
if err := processItem(item); err != nil {
errs <- fmt.Errorf("%s: %w", item, err)
}
}
}()
}
// Send work
for _, item := range items {
jobs <- item
}
close(jobs)
// Wait and collect errors
wg.Wait()
close(errs)
var allErrors []error
for err := range errs {
allErrors = append(allErrors, err)
}
return errors.Join(allErrors...)
}
Cross-Compilation
# Build for multiple targets
GOOS=linux GOARCH=amd64 go build -o dist/mycli-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -o dist/mycli-darwin-amd64 .
GOOS=darwin GOARCH=arm64 go build -o dist/mycli-darwin-arm64 . # M1/M2
GOOS=windows GOARCH=amd64 go build -o dist/mycli-windows-amd64.exe .
# Smaller binary (strip debug info)
go build -ldflags="-s -w" -o mycli .
# Or use goreleaser to automate all of the above + GitHub releases
Making It Testable
// Inject dependencies instead of calling os.Exit in business logic
type App struct {
client *APIClient
out io.Writer // inject output for testing
errOut io.Writer
}
func (a *App) List(format string, limit int) error {
items, err := a.client.List(limit)
if err != nil {
return err
}
return printItems(a.out, items, format)
}
// In tests:
func TestList(t *testing.T) {
var buf bytes.Buffer
app := &App{
client: &mockClient{items: testItems},
out: &buf,
errOut: io.Discard,
}
err := app.List("text", 10)
require.NoError(t, err)
assert.Contains(t, buf.String(), "expected output")
}
The Bottom Line
Go produces fast, self-contained CLIs that ship as a single binary. The pattern above scales from a 200-line tool to a 20,000-line tool without changing structure.
The rules:
- Use cobra for anything with subcommands,
flagpackage for simple tools - Write to stderr for errors and logs; stdout for output (scripts depend on this)
- Respect NO_COLOR and check if stdout is a terminal before coloring
- Support stdin pipes — unix tools compose
- Use
RunEnotRun— return errors, don't print and exit - Cross-compile for your users' platforms before you distribute
Single binary, fast startup, no dependency hell. Go is the right tool for the job.