Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Golang Cobra: Build the CLI, Skip the Generator

Sean

Platform Writer

Aug 30, 2026
9 min read

Cobra is three concepts — a root command, subcommands, and flags — and most of the friction people hit with it comes from the code generator rather than the library.

Golang Cobra: Build the CLI, Skip the Generator

Cobra is the de facto CLI framework for Go, and if you have used kubectl, docker or gh you have used something built on it. The library is small and stable. The scaffolding tool that ships alongside it, cobra-cli, generates a project layout that is fine for large tools and heavy-handed for everything else.

This builds the same thing by hand, which is about fifteen lines more code and considerably clearer about what is happening.

Table of contents

The root command

A Cobra program is a tree of *cobra.Command values. The root is the bare binary invocation:

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

func main() {
	root := &cobra.Command{
		Use:   "deployer",
		Short: "Deploy services and inspect their status",
		Long:  "deployer builds, ships and inspects services from the command line.",
	}

	if err := root.Execute(); err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
}

Use is the name shown in help output and the token Cobra matches against. Short appears in the parent’s command list; Long appears in that command’s own help. With no Run function, invoking the bare binary prints help, which is the right default for a tool that only does things through subcommands.

Install with go get github.com/spf13/cobra@latest. The cobra-cli generator is a separate install and you do not need it.

Subcommands

Subcommands are commands added to a parent. Nesting works to any depth:

func newDeployCmd() *cobra.Command {
	var dryRun bool

	cmd := &cobra.Command{
		Use:   "deploy [service]",
		Short: "Deploy a service",
		Args:  cobra.ExactArgs(1),
		RunE: func(cmd *cobra.Command, args []string) error {
			if dryRun {
				fmt.Fprintf(cmd.OutOrStdout(), "would deploy %s\n", args[0])
				return nil
			}
			return deploy(args[0])
		},
	}

	cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print the plan without deploying")
	return cmd
}

// in main: root.AddCommand(newDeployCmd())

Three things here are deliberate and worth copying.

Use RunE, not Run. RunE returns an error, which Cobra prints and turns into a non-zero exit code. Run returns nothing, so you end up calling os.Exit from inside command logic and lose the ability to test it.

Use the Args validators. cobra.ExactArgs(1), MinimumNArgs, NoArgs and friends give correct usage errors for free instead of an index-out-of-range panic.

Return a command from a constructor function. Package-level command variables with init() registration — the pattern cobra-cli generates — create hidden ordering dependencies and make commands almost impossible to test in isolation.

Flags: local, persistent, and required

Cobra has two flag scopes and the distinction matters:

  • cmd.Flags() — local. Available to this command only.
  • cmd.PersistentFlags() — inherited by this command and every descendant.

Persistent flags on the root are the right home for cross-cutting concerns — --config, --verbose, --output. Everything else should be local, or the help output for every subcommand fills with flags that do not apply to it.

root.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path")
root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")

cmd.Flags().StringVarP(&env, "env", "e", "staging", "target environment")
_ = cmd.MarkFlagRequired("env")

The P suffix variants take a single-letter shorthand. Be sparing: short flags are a small namespace and reusing one for different meanings across subcommands is a lasting irritation for users.

Configuration precedence without the ceremony

CLIs usually need flags to beat environment variables to beat a config file to beat defaults. Cobra pairs with Viper for this, and cobra-cli wires it up for you, but the wiring is small enough to write directly and much easier to debug when precedence surprises you.

func resolve(cmd *cobra.Command, name, envKey, fallback string) string {
	if cmd.Flags().Changed(name) {
		v, _ := cmd.Flags().GetString(name)
		return v
	}
	if v := os.Getenv(envKey); v != "" {
		return v
	}
	return fallback
}

Flags().Changed(name) is the key call: it distinguishes a flag the user explicitly set from one sitting at its default. Without it, a default value silently beats an environment variable, which is the precedence bug people spend an afternoon on.

Reach for Viper when you genuinely need multi-format config files and live reloading. For a tool reading a handful of settings, twenty lines like the above is less machinery and no mystery.

Making commands testable

This is where the constructor-function pattern pays off. Because the command writes to cmd.OutOrStdout() rather than fmt.Println, output can be captured:

func TestDeployDryRun(t *testing.T) {
	var out bytes.Buffer

	cmd := newDeployCmd()
	cmd.SetOut(&out)
	cmd.SetErr(&out)
	cmd.SetArgs([]string{"api", "--dry-run"})

	if err := cmd.Execute(); err != nil {
		t.Fatalf("execute: %v", err)
	}
	if !strings.Contains(out.String(), "would deploy api") {
		t.Errorf("unexpected output: %q", out.String())
	}
}

Always write through cmd.OutOrStdout() and cmd.ErrOrStderr() in command code. It costs nothing, and it is the difference between a CLI with tests and a CLI where every test spawns a subprocess.

Shipping the binary

A Go CLI compiles to a static binary, which is the whole appeal. Cross-compile with GOOS and GOARCH, and stamp the version at build time rather than hardcoding it:

go build -ldflags "-X main.version=$(git describe --tags --always)" -o deployer .

Wire that into a version subcommand and every bug report tells you which build it came from.

Where CLIs stop being self-contained is when they talk to something. A deployment tool needs an API to call, that API needs a database, and both need to be running somewhere before the CLI is useful. Go services deploy on RunxBuild from a repository with a build log, a live route and rollback to the previous deploy, with a managed Postgres or MySQL available on the same platform for the state behind the API.

How this fits the rest of the stack

Cobra is a small library wearing a large reputation: a root command, subcommands returned from constructor functions, RunE for errors, and explicit flag scoping covers nearly every CLI worth writing. Skip the generator until a project is big enough to need the structure. When the CLI grows a backend, the API and its database are the parts with a running cost — the RunxBuild hosting calculator puts the service and the database on one page.

Useful related references:

FAQ

Do I need cobra-cli to use Cobra?

No. cobra-cli is a scaffolding generator, entirely separate from the library. Writing the root command and subcommands by hand is about fifteen extra lines and produces a clearer, more testable structure for small and medium CLIs.

What is the difference between Run and RunE?

RunE returns an error that Cobra prints before exiting non-zero; Run returns nothing, so error handling ends up as os.Exit calls inside command logic. Always use RunE — it keeps commands testable and exit codes correct.

When should I use PersistentFlags?

For flags that genuinely apply to a command and all its descendants — --config, --verbose, --output. Everything else belongs on Flags(), or every subcommand’s help fills with options that do not apply to it.

Do I need Viper with Cobra?

Only for multi-format config files or live reloading. For a handful of settings, a small resolve function checking Flags().Changed(), then the environment, then a default is less machinery and far easier to debug when precedence goes wrong.

How do I test a Cobra command?

Build the command from a constructor function, call SetOut, SetErr and SetArgs, then Execute(). This works only if command code writes through cmd.OutOrStdout() rather than fmt.Println, so adopt that from the start.

#golang cobra#Go CLI#spf13 cobra#Go flags#command line tools