How to Add Subcommands to a Go CLI with Cobra

Cli
Add subcommands to a Cobra CLI by defining a new Command struct and appending it to the parent command's Commands slice.

The flag soup problem

You built a command-line tool that does one thing well. It compiles, it runs, it prints output. Then a user asks for a feature: "Can I just generate the config file without running the whole thing?" You add a flag --generate-only. Then another user wants to validate the config. Now you have --validate-only. The flag list is growing. The help text is a mess. The code is a tangled if flag == "generate" { ... } else if flag == "validate" { ... } block.

This is the moment to stop adding flags and start adding subcommands.

Cobra treats your CLI like a tree. The root is the binary name. Branches are subcommands. Each branch can have its own flags, its own help text, and its own logic. This keeps concerns separated. The run command doesn't need to know about build. The build command doesn't need to know about test. They share the root, but they live in their own namespaces. This mirrors how users think about tools. You don't pass a --mode flag to git. You type git commit or git push. The verb tells the tool what to do.

The command tree structure

A Cobra CLI is a hierarchy of *cobra.Command structs. The root command represents the binary itself. Subcommands are children attached to the root or to other subcommands. Each command defines what happens when the user types that specific verb.

Here's the skeleton: a root command, a subcommand, and the wiring in init.

package main

import (
	"fmt"
	"os"

	"github.com/spf13/cobra"
)

// rootCmd is the entry point for the CLI tree
var rootCmd = &cobra.Command{
	Use:   "mytool",
	Short: "A simple CLI tool",
}

// cmdRun handles the 'run' subcommand logic
var cmdRun = &cobra.Command{
	Use:   "run",
	Short: "Run the application",
	Run: func(cmd *cobra.Command, args []string) {
		fmt.Println("Running...")
	},
}

func init() {
	// Register cmdRun under rootCmd so 'mytool run' works
	rootCmd.AddCommand(cmdRun)
}

func main() {
	// Execute parses args and calls the matching command's Run function
	if err := rootCmd.Execute(); err != nil {
		os.Exit(1)
	}
}

The tree grows in init. The logic lives in Run.

How execution flows

When you run mytool run, Cobra parses the arguments. It sees run matches a registered subcommand. It calls cmdRun.Run. If you run mytool with no args, it prints the root help. If you run mytool unknown, it prints an error.

The init function runs before main. This is where you attach commands. Go runs init in package initialization order. Cobra relies on this to build the tree before execution starts. The community convention is to define commands as package-level variables and wire them in init. This keeps the registration logic separate from the command definition. If you put AddCommand inside main, the code works, but it breaks the pattern that makes Cobra commands composable across packages.

Real tools need flags and errors

Real tools need flags and error handling. Here's a subcommand that takes a flag and returns an error.

// cmdDeploy handles deployment with environment flags
var cmdDeploy = &cobra.Command{
	Use:   "deploy",
	Short: "Deploy the application",
	RunE: func(cmd *cobra.Command, args []string) error {
		// GetString returns the value and a bool indicating if the flag was set
		env, _ := cmd.Flags().GetString("env")
		if env == "" {
			return fmt.Errorf("environment flag is required")
		}
		fmt.Printf("Deploying to %s\n", env)
		return nil
	},
}

func init() {
	// Bind the flag to this specific subcommand
	cmdDeploy.Flags().StringP("env", "e", "dev", "target environment")
	rootCmd.AddCommand(cmdDeploy)
}

Notice RunE instead of Run. The E stands for Error. RunE returns an error. Cobra catches that error, prints it to stderr, and exits with code 1. This is better than fmt.Println(err); os.Exit(1). It keeps error handling consistent across the tree. Use RunE unless you have a reason not to.

Convention aside: Cobra encourages RunE over Run. The boilerplate of returning an error is small. The benefit of centralized error formatting and exit codes is large. If you use Run, you must handle errors manually. If you forget, the tool exits with code 0 even when something failed. That's a silent bug.

Flags live at different levels

Flags can live at different levels. Some flags apply to a single command. Some apply to the whole tool. Cobra distinguishes these with Flags and PersistentFlags.

func init() {
	// Persistent flags are inherited by all child commands
	rootCmd.PersistentFlags().StringP("config", "c", "config.yaml", "path to config file")

	// Local flags only apply to this command
	cmdDeploy.Flags().StringP("env", "e", "dev", "target environment")
	rootCmd.AddCommand(cmdDeploy)
}

PersistentFlags propagate down the command tree. If you define --config on the root, every subcommand can access it. Flags stay scoped to the command where you defined them. This lets you share global options like --verbose or --config while keeping command-specific options isolated.

Commands have a lifecycle

Commands have a lifecycle. Cobra runs hooks before and after the main logic. This is useful for validation, setup, and cleanup.

var cmdDeploy = &cobra.Command{
	Use: "deploy",
	PreRunE: func(cmd *cobra.Command, args []string) error {
		// PreRunE runs before RunE, useful for validation
		env, _ := cmd.Flags().GetString("env")
		if env == "prod" {
			return fmt.Errorf("production deploys require confirmation")
		}
		return nil
	},
	RunE: func(cmd *cobra.Command, args []string) error {
		// Main logic runs only if PreRunE succeeds
		env, _ := cmd.Flags().GetString("env")
		fmt.Printf("Deploying to %s\n", env)
		return nil
	},
}

PreRunE runs before RunE. If PreRunE returns an error, RunE never runs. This is the right place for validation. PostRunE runs after RunE, even if RunE returns an error. Use it for cleanup. The lifecycle hooks let you separate concerns: validation in PreRunE, work in RunE, cleanup in PostRunE.

Pitfalls and compiler errors

If you define a command but forget rootCmd.AddCommand(cmd), the binary compiles fine. The subcommand just doesn't exist at runtime. You get unknown command from Cobra, not the compiler. The compiler won't save you here. Test the CLI like a user.

If you use RunE but the signature is wrong, the compiler rejects the program. You might assign a Run function to RunE by mistake. The compiler complains with cannot use func literal (type func(*cobra.Command, []string)) as type func(*cobra.Command, []string) error in field value. The fix is to add error to the return type and return nil on success.

Convention aside: RunE must return error. If you return nil, it's success. If you return an error, Cobra handles the rest. Don't swallow errors in RunE. Return them. Let the framework do its job.

Goroutine leaks can happen if you spawn background work in a command and forget to wait for it. Cobra commands run synchronously by default. If you start a goroutine, you need a way to stop it. Use context.Context to signal cancellation. Pass the context through your long-running calls. If the user interrupts with Ctrl+C, Cobra cancels the context. Your goroutine should check the context and exit.

The worst goroutine bug is the one that never logs. Always have a cancellation path.

When to use subcommands

Use Cobra subcommands when your tool has distinct modes of operation that require different flags or help text. Use root-level flags when the option applies to every action, like --verbose or --config. Use a simple flag parser when the tool does one thing and won't grow. Use urfave/cli or kong when you need advanced features like nested groups or YAML config binding that Cobra doesn't provide out of the box.

Structure follows usage. If the user thinks in verbs, build subcommands.

Where to go next