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 argscobra.ExactArgs(n)— requires exactly n argscobra.MinimumNArgs(n)— requires at least n argscobra.MaximumNArgs(n)— allows at most n argscobra.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(notRun) for all commands — it returns errors that Cobra handles cleanly PersistentFlags()for flags shared across subcommands;Flags()for command-specific flagsMarkFlagRequiredfor 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 inRunE— enables testing without running the CLI
Comments