Skip to main content

Building CLI Tools with Cobra in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

Cobra is the CLI framework behind kubectl, helm, hugo, gh, and most other major Go CLI tools. It handles subcommand routing, flag management, help generation, and shell completion — the scaffolding that would otherwise take hundreds of lines to write by hand.

The core idea: a command tree where each node is a *cobra.Command with its own flags and RunE function. Cobra routes os.Args to the right command and handles everything around it.

For the standard library alternative see Go command-line parsing flags.

Installation and Project Structure

go get github.com/spf13/cobra@latest

The conventional structure for a Cobra CLI:

myapp/
├── cmd/
│   ├── root.go      # root command, persistent flags
│   ├── serve.go     # serve subcommand
│   ├── deploy.go    # deploy subcommand
│   └── version.go   # version subcommand
├── internal/
│   └── ...          # business logic (no cmd imports)
└── main.go          # calls cmd.Execute()

main.go is minimal — just the entry point:

package main

import "myapp/cmd"

func main() {
    cmd.Execute()
}

The Root Command

cmd/root.go defines the root command and persistent flags — flags that apply to every subcommand:

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

var (
    cfgFile string
    verbose bool
)

var rootCmd = &cobra.Command{
    Use:   "myapp",
    Short: "A tool for managing resources",
    Long: `myapp manages your application resources.

Complete documentation: https://myapp.example.com/docs`,
}

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

func init() {
    // Persistent flags: available to root and all subcommands
    rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file path")
    rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")

    // PersistentPreRun runs before any subcommand's Run
    rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
        return initConfig(cfgFile)
    }
}

PersistentFlags() makes flags available to the command and all its children. Flags() makes flags available only to the specific command. The distinction matters when you add more subcommands — persistent flags don’t need to be redefined.

Adding Subcommands

Each subcommand is its own file in cmd/. The command registers itself on the root in its init():

// cmd/serve.go
package cmd

import (
    "context"
    "fmt"

    "github.com/spf13/cobra"
    "myapp/internal/server"
)

var serveCmd = &cobra.Command{
    Use:   "serve",
    Short: "Start the HTTP server",
    Long:  "Start the HTTP server on the specified host and port.",
    // RunE returns an error — the right choice for commands that can fail
    RunE: func(cmd *cobra.Command, args []string) error {
        port, _ := cmd.Flags().GetInt("port")
        host, _ := cmd.Flags().GetString("host")

        if verbose {  // persistent flag from root
            fmt.Printf("starting server on %s:%d\n", host, port)
        }

        srv, err := server.New(host, port)
        if err != nil {
            return fmt.Errorf("creating server: %w", err)
        }
        return srv.Start(cmd.Context())
    },
}

func init() {
    rootCmd.AddCommand(serveCmd)

    // Local flags — only for serve subcommand
    serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
    serveCmd.Flags().StringP("host", "H", "0.0.0.0", "host to bind to")
}

RunE (not Run) is the correct choice — it returns an error that Cobra prints and exits non-zero on. With plain Run, you’d have to call os.Exit(1) yourself, making commands harder to test.

Argument Validation

Cobra provides built-in argument validators to enforce positional argument counts:

var deleteCmd = &cobra.Command{
    Use:   "delete <resource-type> <resource-id>",
    Short: "Delete a resource by ID",
    Args:  cobra.ExactArgs(2),  // exits with error if not exactly 2 args
    RunE: func(cmd *cobra.Command, args []string) error {
        resourceType := args[0]
        resourceID   := args[1]
        force, _     := cmd.Flags().GetBool("force")

        if !force {
            confirmed, err := promptConfirm(fmt.Sprintf("delete %s %s?", resourceType, resourceID))
            if err != nil || !confirmed {
                return fmt.Errorf("cancelled")
            }
        }

        return deleteResource(cmd.Context(), resourceType, resourceID)
    },
}

Built-in validators:

  • cobra.NoArgs — rejects any positional args
  • cobra.ExactArgs(n) — requires exactly n args
  • cobra.MinimumNArgs(n) — requires at least n args
  • cobra.MaximumNArgs(n) — allows at most n args
  • cobra.RangeArgs(min, max) — requires between min and max args

For custom validation (e.g., args must be valid resource types), use cobra.MatchAll or a custom ValidArgs function:

validTypes := []string{"user", "product", "order"}

var getCmd = &cobra.Command{
    Use:       "get <resource-type>",
    ValidArgs: validTypes,  // enables tab completion for first arg
    Args: cobra.MatchAll(
        cobra.ExactArgs(1),
        cobra.OnlyValidArgs,  // rejects args not in ValidArgs
    ),
    RunE: func(cmd *cobra.Command, args []string) error {
        return getResource(cmd.Context(), args[0])
    },
}

Marking Required Flags

Use MarkFlagRequired to fail with a clear message if a required flag is missing:

var createCmd = &cobra.Command{
    Use:   "create",
    Short: "Create a new resource",
    RunE: func(cmd *cobra.Command, args []string) error {
        name, _ := cmd.Flags().GetString("name")
        kind, _ := cmd.Flags().GetString("kind")
        return createResource(cmd.Context(), kind, name)
    },
}

func init() {
    rootCmd.AddCommand(createCmd)

    createCmd.Flags().StringP("name", "n", "", "resource name")
    createCmd.Flags().StringP("kind", "k", "", "resource kind (user, product, order)")

    createCmd.MarkFlagRequired("name")  // error: required flag "name" not set
    createCmd.MarkFlagRequired("kind")
}

Without MarkFlagRequired, missing flags silently use their zero value — often a source of subtle bugs.

Viper: Configuration File Integration

Viper binds configuration files, environment variables, and flags together. Cobra + Viper is the standard combination for tools that need persistent configuration:

import (
    "github.com/spf13/viper"
    "github.com/spf13/cobra"
)

func initConfig(cfgFile string) error {
    if cfgFile != "" {
        viper.SetConfigFile(cfgFile)
    } else {
        home, _ := os.UserHomeDir()
        viper.AddConfigPath(home)
        viper.AddConfigPath(".")
        viper.SetConfigName(".myapp")  // reads .myapp.yaml, .myapp.json, etc.
    }

    viper.SetEnvPrefix("MYAPP")  // MYAPP_HOST maps to host
    viper.AutomaticEnv()

    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); ok {
            return nil  // no config file is fine
        }
        return fmt.Errorf("reading config: %w", err)
    }
    return nil
}

// Bind a flag to a viper key — viper value used when flag not set
func init() {
    cobra.OnInitialize(func() { initConfig(cfgFile) })

    rootCmd.PersistentFlags().StringP("api-url", "", "https://api.example.com", "API endpoint")
    viper.BindPFlag("api_url", rootCmd.PersistentFlags().Lookup("api-url"))
}

// In commands, read from viper (works regardless of source: flag, env, file)
apiURL := viper.GetString("api_url")

Priority order: command-line flags > environment variables > config file > defaults.

Shell Completion

Cobra generates shell completion scripts for bash, zsh, fish, and PowerShell:

var completionCmd = &cobra.Command{
    Use:   "completion [bash|zsh|fish|powershell]",
    Short: "Generate shell completion script",
    Args:  cobra.ExactArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        switch args[0] {
        case "bash":
            return rootCmd.GenBashCompletion(os.Stdout)
        case "zsh":
            return rootCmd.GenZshCompletion(os.Stdout)
        case "fish":
            return rootCmd.GenFishCompletion(os.Stdout, true)
        case "powershell":
            return rootCmd.GenPowerShellCompletionWithDesc(os.Stdout)
        default:
            return fmt.Errorf("unsupported shell: %s", args[0])
        }
    },
}

Users install completion once:

myapp completion bash > /etc/bash_completion.d/myapp
myapp completion zsh > "${fpath[1]}/_myapp"

Testing Commands

Testable commands require injectable dependencies. Keep business logic out of RunE — call a function from your internal package that accepts a context and returns an error:

// internal/resources/delete.go
func Delete(ctx context.Context, kind, id string) error { ... }

// cmd/delete.go — thin wrapper
var deleteCmd = &cobra.Command{
    RunE: func(cmd *cobra.Command, args []string) error {
        return resources.Delete(cmd.Context(), args[0], args[1])
    },
}

Test the command by capturing output:

func TestDeleteCommand(t *testing.T) {
    // Replace real deleter with a fake
    origDelete := resources.Delete
    resources.Delete = func(ctx context.Context, kind, id string) error {
        return nil  // success
    }
    defer func() { resources.Delete = origDelete }()

    outBuf := new(bytes.Buffer)
    errBuf := new(bytes.Buffer)
    rootCmd.SetOut(outBuf)
    rootCmd.SetErr(errBuf)
    rootCmd.SetArgs([]string{"delete", "user", "u-123"})

    err := rootCmd.Execute()
    if err != nil {
        t.Fatalf("unexpected error: %v", err)
    }
}

For more complete CLI testing patterns see Go testing CLI applications.

Summary

  • Use RunE (not Run) for all commands — it returns errors that Cobra handles cleanly
  • PersistentFlags() for flags shared across subcommands; Flags() for command-specific flags
  • MarkFlagRequired for mandatory flags — clear error message, no silent zero-value behavior
  • Built-in argument validators (cobra.ExactArgs, cobra.MinimumNArgs) keep commands clean
  • Bind flags to Viper with viper.BindPFlag — flags, env vars, and config files work transparently
  • Keep business logic in internal/ packages, not in RunE — enables testing without running the CLI

Resources

Comments

👍 Was this article helpful?