Why Go Is Great for CLI Tools
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:
flagoros.Argsfor argument parsing (or reach forcobrawhen you need subcommands)os/execfor running subprocessesbufioandosfor file I/Onet/httpfor making HTTP requests or standing up a simple serverencoding/json,encoding/csv,text/templatefor data handlingsyncandcontextfor 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.