Why Go is a compelling choice for building CLI tooling

I'm no Go expert, but even with limited knowledge, it's clear that Go was designed with CLI tools in mind.

My example

I want to begin with my practical example so that I can later just refer to it. I built a simple proof of concept tool, called kc / Kartones CLI, that provides three commands:

  • help: Shows information about all available commands, or about a specific command. It is also the default command if none specified.
  • read-config <file>: Example of parsing JSON and YAML files.
  • list-dir <directory>: Example of using the os package to list files and subfolders

It uses one external library (yaml), but zero C++ bindings/cgo (more on why this is relevant later).

Built with Bazel, trying to apply my limited Go knowledge and some good practices that I read to produce something easily readable. It comes without tests, but I manually tested both macOS and Windows binaries (ARM64 both).

You can find it here: https://github.com/Kartones/go-cli

macOS screenshot

Windows screenshot

Other typical scenarios, not implemented but easy to add, would be performing HTTP requests and downloading artifacts (via net/http), running sub-processes that execute external commands (via os/exec) and modifying text files (via os, io, bufio, ...). All of them, doable either with the built-in packages, or with packages that simplify and extend some use cases.

Bazel as the build system

If you pair Go with Bazel, you can easily spot how Go clearly was built using Bazel, as the integration is so good and simple. I wish it were the same for other languages.

You only need to use rules_go, and optionally Gazelle so you don't need to manually maintain Go-related files in Bazel.

In my example project, the only thing that I did differently was provide a convenience copy of the binary at the root of the project, merely for a smaller execution path. Otherwise, I could have just let Gazelle do its work, and then add the go_cross_binary() targets in the same package that go_binary() is.

Bazel gives reproducible builds and strong cross-compilation support, but it comes with a learning curve and configuration overhead, especially for smaller projects. That said, in the case of Go, this overhead is smaller.

Embedded Runtime

A hello world in Go will weight around 2 MB. But as of 2025, anything that is less than 10 MB is a quick download, and a fully-fledged CLI tool will probably not even reach 5 MB. This example kc weights 2.5 MB, including a 3rd party YAML parsing library, and the most critical part, it includes the whole Go runtime embedded in the binary. If I compile the macOS ARM64 version, any Apple Silicon macOS will be able to run it out of the box, without any additional installation. The same goes for Windows, or for Linux.

This is an excellent tradeoff to make: If you're building tools to either serve software engineers, or to use inside Continuous Integration jobs, or both cases, now you have something that does not need anything else: No more Visual C++ Runtime, no more apt packages, no more JDKs/JREs, no more Python versioning issues and virtual environments to manage...

To me, it's clear why Google chose this path: It's easy to follow the Linux principle of small independent tools, in this case also fully portable. And speaking on the topic of portability, let's jump to something closely related.

Note: If you compile through Bazel, the binary rules by default generate binaries stripped out of the debugging symbols (meaning: smaller file sizes).

Cross-compilation

In my opinion, Go features two superpowers. I just mentioned the first one, embedding the runtime inside binaries. As for the second, you can easily cross-compile your source code and generate binaries in for other platforms. The general information can be found at https://go.dev/wiki/GccgoCrossCompilation and https://go.dev/doc/install/source#environment. As I like to use Bazel, then it becomes a one-liner per desired platform:

go_cross_binary(
    name = "kc_windows_amd64",
    platform = "@rules_go//go/toolchain:windows_amd64",
    target = ":kc",
)

You can check the whole list of cross-compilation targets in the main BUILD.bazel file.

Now imagine having a single CI job, running on a cheap Linux node, that compiles in seconds the latest version of your internal CLI tool, after every merged changeset, for every platform you support, and deploys it to your artifact repository of choice. Then is up to you to decide the update mechanism and frequency, but it is almost as easy as pushing a script.

There is one caveat to this remarkable feature: The ease of use applies as long as you don't adventure into cgo land (C bindings for Go). If you do, cross-compilation is still somewhat possible, but way harder, even using Bazel.

But what about updates?

There are a few approaches here, and I won't cover them much (nor I have implemented any in my sample repository), but I'll mention the one that I think works best.

I like the approach of having an extra updater binary. Whenever we want to trigger an update, we:

  1. Launch the updater binary from the main binary
  2. The updater binary starts by waiting until the main binary has stopped running
  3. The main binary exits
  4. The updater binary downloads a new binary version (e.g. as kc.new)
  5. The updater binary deletes the old binary and renames the new to the old (kc.newkc)
  6. The updater launches the main binary, and exits

We need to ship two binaries instead of one, but this allows for pseudo-hot swaps without complexities of scripts or symlinks (valid alternatives, with different caveats).

Note: The go-update package seems like a good alternative, but I have not tried it.

Conclusion

Scripting is faster to write but can become harder to manage across platforms and environments. Go instead is slower to iterate but produces portable binaries. I think that Go should be used more widely for internal tools, specially in scenarios where you need to factor in distribution.

Pick the right tool for the job.

Tags: Architecture Bazel Development Gazelle Go Linux macOS Operating Systems Python Tools Windows

Why Go is a compelling choice for building CLI tooling article, written by Kartones. Published on