I’ve built CLI tools in Python, Node.js, and Go. Go has become my default for anything I want to actually ship and have other people use. Here’s why.

Single Binary, Zero Dependencies

When you build a Go CLI, you get a single statically linked binary. No runtime to install, no npm install, no virtualenv. You can drop the binary on any machine with the right OS/architecture and it just works.

GOOS=linux GOARCH=amd64 go build -o mytool-linux-amd64 .
GOOS=darwin GOARCH=arm64 go build -o mytool-darwin-arm64 .
GOOS=windows GOARCH=amd64 go build -o mytool-windows-amd64.exe .

Cross-compilation is built in and works without any additional tooling. For distribution, this is a huge win — you can post binaries to a GitHub release and users can curl them directly.

Fast Startup

Go binaries start in milliseconds. This matters more than it sounds for CLIs, because CLIs are invoked repeatedly — in shell scripts, Makefiles, git hooks, CI pipelines. A tool with 200ms startup time is annoying; one with 2ms is invisible.

Python and Node.js tools can have startup times measured in seconds depending on what you import. Go doesn’t have this problem.

The Standard Library Covers Most Needs

For CLI tools, Go’s standard library is remarkably complete:

  • flag or os.Args for argument parsing (or reach for cobra when you need subcommands)
  • os/exec for running subprocesses
  • bufio and os for file I/O
  • net/http for making HTTP requests or standing up a simple server
  • encoding/json, encoding/csv, text/template for data handling
  • sync and context for concurrent work with cancellation

You can build a lot without any third-party dependencies.

Error Handling Forces Explicitness

Go’s explicit error returns are famously verbose, but for CLI tools this is actually a feature. You’re forced to think about what happens when things go wrong at every step:

f, err := os.Open(path)
if err != nil {
    fmt.Fprintf(os.Stderr, "error: could not open file: %v\n", err)
    os.Exit(1)
}
defer f.Close()

This leads to tools that give useful error messages instead of crashing with an unhandled exception and a stack trace.

A Practical Structure

For most CLI tools I start with this layout:

mytool/
├── cmd/
│   └── root.go       # Cobra root command and subcommands
├── internal/
│   ├── config/       # Config file loading
│   └── core/         # Core logic, separate from CLI concerns
├── main.go           # Entry point, minimal
└── go.mod

Keeping the core logic in internal/ and separate from the Cobra commands makes the tool easier to test — you can unit test the logic without standing up the whole CLI.

Cobra for Subcommands

For tools with multiple subcommands, Cobra is the standard choice. It handles subcommand dispatch, flag parsing, and help generation:

var rootCmd = &cobra.Command{
    Use:   "mytool",
    Short: "A brief description of my tool",
}

var buildCmd = &cobra.Command{
    Use:   "build [flags]",
    Short: "Build the project",
    RunE: func(cmd *cobra.Command, args []string) error {
        return runBuild(args)
    },
}

func init() {
    rootCmd.AddCommand(buildCmd)
    buildCmd.Flags().StringP("output", "o", "dist", "Output directory")
}

When Go Isn’t the Right Choice

Go’s strength is distribution simplicity and performance. If you’re building a tool that’s only ever going to run in an environment where Python or Node is already present, the friction difference disappears.

Shell scripts are still the right tool for gluing together existing Unix utilities. Go shines when you need cross-platform portability, non-trivial logic, or reliability at scale.

For everything in between, Go is worth the small upfront cost of learning the language.