| name | cobra-viper-best-practices |
| description | Reference knowledge base for the go-cobra agent. Loaded by that agent on its first iteration when the host has not already injected it; not intended for direct invocation. |
Cobra & Viper Best Practices
A comprehensive reference guide for building idiomatic CLI applications in Go using Cobra and Viper. This document serves as the knowledge base for the go-cobra pattern.
Table of Contents
- Command Design Philosophy
- Project Structure
- Command Implementation
- Flag Management
- Viper Configuration
- Cobra + Viper Integration
- Error Handling
- Testing Strategies
- Shell Completions
- Production Patterns
- Anti-Patterns
- Severity Classification
Command Design Philosophy
Natural Command Syntax
Commands should read like natural sentences. Follow the pattern:
APPNAME VERB NOUN --ADJECTIVE
APPNAME COMMAND ARG --FLAG
Good Examples:
git clone URL --depth 1
kubectl get pods --namespace kube-system
docker run IMAGE --detach
myapp deploy production --dry-run
Bad Examples:
myapp --deploy production
myapp production-deploy
myapp do_deployment --env=prod
Command Hierarchy
| Level | Purpose | Example |
|---|
| Root | Application entry point | myapp |
| Command | Primary action | myapp serve |
| Subcommand | Action refinement | myapp config set |
| Arguments | Required input | myapp deploy prod |
| Flags | Optional modifiers | --verbose, --port 8080 |
Naming Conventions
- Commands: Short, verb-based (
serve, run, get, set)
- Arguments: Noun-based, clear purpose
- Flags: Descriptive, kebab-case (
--log-level, --dry-run)
- Aliases: Provide short forms (
-v for --verbose)
Project Structure
Recommended Layout
myapp/
├── main.go # Minimal entry point
├── cmd/
│ ├── root.go # Root command and global config
│ ├── serve.go # Subcommand: serve
│ ├── migrate.go # Subcommand: migrate
│ └── version.go # Subcommand: version
├── internal/
│ └── app/ # Business logic (testable, CLI-independent)
└── config/
└── config.go # Configuration structs and loading
Critical Rules
| Rule | Severity | Rationale |
|---|
| Minimal main.go | HIGH | Entry point only initializes and executes |
| One command per file | MEDIUM | Maintainability and clarity |
| Business logic in internal/ | HIGH | Separation of concerns, testability |
| Config structs separate | MEDIUM | Reusable, type-safe configuration |
Minimal main.go
Good:
package main
import (
"os"
"myapp/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
Bad:
package main
import (
"fmt"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{...}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
Command Implementation
Command Structure
var exampleCmd = &cobra.Command{
Use: "example [flags] <arg>",
Short: "One-line description (shown in help lists)",
Long: `Extended description with examples and details.
Shown when running 'myapp example --help'.`,
Example: ` myapp example foo
myapp example bar --verbose`,
Args: cobra.ExactArgs(1),
Aliases: []string{"ex", "eg"},
RunE: runExample,
}
Critical Checks
| Check | Severity | Rationale |
|---|
| Use RunE not Run | HIGH | Proper error propagation |
| Args validation | MEDIUM | User feedback before execution |
| Short description | MEDIUM | Help readability |
| Example usage | LOW | User guidance |
RunE vs Run
Good - RunE:
RunE: func(cmd *cobra.Command, args []string) error {
if err := doSomething(); err != nil {
return fmt.Errorf("failed to process: %w", err)
}
return nil
}
Bad - Run:
Run: func(cmd *cobra.Command, args []string) {
doSomething()
}
Argument Validation
Built-in validators:
Args: cobra.NoArgs
Args: cobra.ExactArgs(2)
Args: cobra.MinimumNArgs(1)
Args: cobra.MaximumNArgs(3)
Args: cobra.RangeArgs(1, 3)
Args: cobra.OnlyValidArgs
Custom validation:
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("requires at least one argument")
}
if !isValidInput(args[0]) {
return fmt.Errorf("invalid input: %q", args[0])
}
return nil
}
Command Lifecycle Hooks
Execution order:
PersistentPreRun (inherited by children)
PreRun
Run / RunE
PostRun
PersistentPostRun (inherited by children)
Good - Use PersistentPreRunE for initialization:
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "My application",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
if err := initConfig(); err != nil {
return fmt.Errorf("init config: %w", err)
}
return nil
},
}
Flag Management
Persistent vs Local Flags
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default: $HOME/.myapp.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false,
"verbose output")
serveCmd.Flags().IntP("port", "p", 8080, "port to listen on")
serveCmd.Flags().Duration("timeout", 30*time.Second, "request timeout")
}
Flag Types Reference
| Type | Method | Example |
|---|
| String | StringVarP | --name value |
| Int | IntVarP | --port 8080 |
| Bool | BoolVarP | --verbose |
| Duration | DurationVarP | --timeout 30s |
| StringSlice | StringSliceVarP | --tag a,b --tag c |
| StringArray | StringArrayVarP | --tag a,b --tag c (no comma split) |
| Count | CountVarP | -v, -vv, -vvv |
Required Flags
serveCmd.Flags().StringP("database", "d", "", "database connection string")
if err := serveCmd.MarkFlagRequired("database"); err != nil {
panic(err)
}
MarkFlagRequired returns an error (when the named flag does not exist) —
handle it, never discard it. And only mark a flag required when its presence
is not already enforced in RunE; duplicating that check is redundant.
Flag Groups
cmd.MarkFlagsRequiredTogether("username", "password")
cmd.MarkFlagsMutuallyExclusive("json", "yaml", "table")
cmd.MarkFlagsOneRequired("config", "inline-config")
Count Flags (Verbosity Pattern)
var verbosity int
func init() {
rootCmd.PersistentFlags().CountVarP(&verbosity, "verbose", "v",
"increase verbosity (-v, -vv, -vvv)")
}
Slice Flags
var tags []string
func init() {
cmd.Flags().StringSliceVar(&tags, "tag", []string{},
"tags (can be repeated or comma-separated)")
cmd.Flags().StringArrayVar(&tags, "tag", []string{},
"tags (can be repeated)")
}
Viper Configuration
Avoid the Global Instance
| Pattern | Severity | Status |
|---|
| Use dedicated Viper instance | HIGH | Recommended |
| Global viper singleton | MEDIUM | Avoid |
Good - Explicit instance:
func NewConfig() (*Config, error) {
v := viper.New()
v.SetConfigName("config")
v.AddConfigPath(".")
return loadConfig(v)
}
Bad - Global singleton:
func init() {
viper.SetConfigName("config")
}
Configuration Precedence
Viper respects this hierarchy (highest to lowest priority):
- Explicit
Set() calls
- Command-line flags
- Environment variables
- Configuration files
- Key/value stores (etcd, Consul)
- Default values
Type-Safe Configuration Structs
type Config struct {
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Log LogConfig `mapstructure:"log"`
}
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Name string `mapstructure:"name"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
}
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
func LoadConfig(v *viper.Viper) (*Config, error) {
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("validate config: %w", err)
}
return &cfg, nil
}
Environment Variables
func setupViper(v *viper.Viper) {
v.SetEnvPrefix("MYAPP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
}
Multi-Location Config Search
func setupConfigPaths(v *viper.Viper) {
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("$HOME/.myapp")
v.AddConfigPath("/etc/myapp")
if cfgFile != "" {
v.SetConfigFile(cfgFile)
}
}
Defaults
func setDefaults(v *viper.Viper) {
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8080)
v.SetDefault("server.read_timeout", "30s")
v.SetDefault("server.write_timeout", "30s")
v.SetDefault("log.level", "info")
v.SetDefault("log.format", "json")
}
Configuration Validation
func (c *Config) Validate() error {
if c.Server.Port < 1 || c.Server.Port > 65535 {
return fmt.Errorf("invalid port: %d (must be 1-65535)", c.Server.Port)
}
if c.Database.Host == "" {
return errors.New("database.host is required")
}
if c.Database.Password == "" {
return errors.New("database.password is required (set MYAPP_DATABASE_PASSWORD)")
}
return nil
}
Cobra + Viper Integration
The Correct Pattern
Key insight: Viper is the single source of truth. Don't read flag values directly - bind them to Viper and read from Viper.
var (
cfgFile string
v *viper.Viper
)
func init() {
v = viper.New()
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "",
"config file (default: $HOME/.myapp.yaml)")
rootCmd.PersistentFlags().String("log-level", "info",
"log level (debug, info, warn, error)")
v.BindPFlag("log.level", rootCmd.PersistentFlags().Lookup("log-level"))
}
func initConfig() {
if cfgFile != "" {
v.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
v.AddConfigPath(home)
v.AddConfigPath(".")
v.SetConfigName(".myapp")
v.SetConfigType("yaml")
}
v.SetEnvPrefix("MYAPP")
v.AutomaticEnv()
if err := v.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", v.ConfigFileUsed())
}
}
Reading Values in Commands
Good - Read from Viper:
func runServe(cmd *cobra.Command, args []string) error {
logLevel := v.GetString("log.level")
port := v.GetInt("server.port")
return startServer(port, logLevel)
}
Bad - Read directly from flags:
func runServe(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetInt("port")
return startServer(port)
}
Error Handling
Return Wrapped Errors
func runMigrate(cmd *cobra.Command, args []string) error {
db, err := connectDB()
if err != nil {
return fmt.Errorf("connect to database: %w", err)
}
defer db.Close()
if err := db.Migrate(); err != nil {
return fmt.Errorf("run migrations: %w", err)
}
return nil
}
Custom Error Prefix
func init() {
rootCmd.SetErrPrefix("Error:")
}
Actionable Error Messages
Good:
return fmt.Errorf("config file not found at %s (create one or use --config)", path)
Bad:
return errors.New("config not found")
Testing Strategies
Extract Business Logic
Separate CLI concerns from business logic:
func runServe(cmd *cobra.Command, args []string) error {
port := v.GetInt("server.port")
timeout := v.GetDuration("server.timeout")
return app.StartServer(port, timeout)
}
func StartServer(port int, timeout time.Duration) error {
}
Dependency Injection
type App struct {
Config *Config
Out io.Writer
Err io.Writer
}
func NewApp(cfg *Config) *App {
return &App{
Config: cfg,
Out: os.Stdout,
Err: os.Stderr,
}
}
func TestApp(t *testing.T) {
out := &bytes.Buffer{}
app := &App{
Config: testConfig(),
Out: out,
}
}
Execute Commands in Tests
func executeCommand(root *cobra.Command, args ...string) (string, error) {
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
root.SetArgs(args)
err := root.Execute()
return buf.String(), err
}
func TestServeCommand(t *testing.T) {
cmd := NewRootCmd()
output, err := executeCommand(cmd, "serve", "--port", "9090")
require.NoError(t, err)
assert.Contains(t, output, "listening on :9090")
}
Table-Driven Tests
func TestCommands(t *testing.T) {
tests := []struct {
name string
args []string
wantErr bool
wantOut string
}{
{
name: "serve with valid port",
args: []string{"serve", "--port", "8080"},
wantErr: false,
},
{
name: "serve with invalid port",
args: []string{"serve", "--port", "99999"},
wantErr: true,
},
{
name: "version",
args: []string{"version"},
wantOut: "v1.0.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := NewRootCmd()
out, err := executeCommand(cmd, tt.args...)
if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}
if tt.wantOut != "" {
assert.Contains(t, out, tt.wantOut)
}
})
}
}
Reset Viper Between Tests
func TestWithConfig(t *testing.T) {
v := viper.New()
v.Set("server.port", 9090)
cmd := NewRootCmdWithViper(v)
}
Shell Completions
Add a Completion Command
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script",
Long: `Generate shell completion script for the specified shell.
To load completions:
Bash (Linux):
$ myapp completion bash > /etc/bash_completion.d/myapp
Bash (macOS):
$ myapp completion bash > /usr/local/etc/bash_completion.d/myapp
Zsh:
$ myapp completion zsh > "${fpath[1]}/_myapp"
Fish:
$ myapp completion fish > ~/.config/fish/completions/myapp.fish
PowerShell:
PS> myapp completion powershell | Out-String | Invoke-Expression
`,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
DisableFlagsInUseLine: true,
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
Dynamic Completions
var deployCmd = &cobra.Command{
Use: "deploy [environment]",
Short: "Deploy to an environment",
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) (
[]string, cobra.ShellCompDirective) {
if len(args) != 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
envs, err := fetchEnvironments()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
return envs, cobra.ShellCompDirectiveNoFileComp
},
}
Flag Completions
func init() {
deployCmd.Flags().StringP("region", "r", "", "AWS region")
deployCmd.RegisterFlagCompletionFunc("region",
func(cmd *cobra.Command, args []string, toComplete string) (
[]string, cobra.ShellCompDirective) {
return []string{
"us-east-1 N. Virginia",
"us-west-2 Oregon",
"eu-west-1 Ireland",
}, cobra.ShellCompDirectiveNoFileComp
})
}
Production Patterns
Version Information
var (
version = "dev"
commit = "unknown"
date = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\nCommit: %s\nBuilt: %s\n",
version, commit, date)
},
}
Graceful Shutdown
func runServe(cmd *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(cmd.Context(),
syscall.SIGINT, syscall.SIGTERM)
defer cancel()
srv := &http.Server{Addr: ":8080"}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)
}()
return srv.ListenAndServe()
}
Live Configuration Reload
func watchConfig(v *viper.Viper, onReload func()) {
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
log.Printf("Config reloaded: %s", e.Name)
onReload()
})
}
Secrets Management
Never store secrets in config files:
database:
host: localhost
port: 5432
name: myapp
export MYAPP_DATABASE_USER=admin
export MYAPP_DATABASE_PASSWORD=secret
Structured Logging Integration
func initLogger(v *viper.Viper) *slog.Logger {
level := slog.LevelInfo
switch v.GetString("log.level") {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
var handler slog.Handler
if v.GetString("log.format") == "json" {
handler = slog.NewJSONHandler(os.Stderr, opts)
} else {
handler = slog.NewTextHandler(os.Stderr, opts)
}
return slog.New(handler)
}
Anti-Patterns
Common Mistakes to Avoid
| Anti-Pattern | Severity | Why It's Bad |
|---|
| Using Run instead of RunE | HIGH | Errors are silently ignored |
| Reading flags directly in commands | HIGH | Bypasses config precedence |
| Global Viper singleton | MEDIUM | Hard to test |
| Business logic in cmd/ | HIGH | Tight coupling, hard to test |
| Complex main.go | MEDIUM | Entry point should be minimal |
| Goroutines in init() | HIGH | Unpredictable startup |
| Ignoring errors from BindPFlag | MEDIUM | Silent configuration issues |
| Hardcoded config paths | MEDIUM | Inflexible deployment |
| Missing shell completions | LOW | Reduced UX |
| Missing version command | LOW | Deployment debugging difficulty |
Anti-Pattern Examples
Bad - Complex main.go:
func main() {
initLogging()
loadConfig()
connectDatabase()
rootCmd.Execute()
}
Good - Minimal main.go:
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
Bad - Flags bypass config:
func runServe(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetInt("port")
}
Good - Use Viper:
func runServe(cmd *cobra.Command, args []string) error {
port := v.GetInt("server.port")
}
Severity Classification
CRITICAL
Issues that affect correctness, security, or cause crashes:
- Using Run instead of RunE with important error handling
- Missing argument validation that could cause panics
- Secrets stored in config files
- No graceful shutdown handling
HIGH
Significant issues affecting reliability or maintainability:
- Reading flags directly instead of using Viper
- Business logic in cmd/ package
- Global Viper singleton
- Missing error context/wrapping
- No configuration validation
MEDIUM
Best practice violations:
- Missing shell completions
- Missing version command
- Complex main.go
- Non-standard project structure
- Missing flag descriptions
LOW
Minor improvements:
- Missing command aliases
- Missing Long descriptions
- Missing Example in commands
- Naming convention tweaks
INFO
Suggestions for optimization:
- Live config reload
- Dynamic completions
- Structured logging integration
- Performance optimizations
Quick Reference Checklist
Before Shipping
Common Patterns Summary
| Pattern | Location | Purpose |
|---|
| Root command | cmd/root.go | Global config, PersistentPreRunE |
| Subcommand | cmd/[name].go | One command per file |
| Config struct | config/config.go | Type-safe configuration |
| Business logic | internal/app/ | Testable, CLI-independent |
| Flag binding | init() | Bind flags to Viper |
| Config loading | PersistentPreRunE | Before any command runs |
References
Last updated: 2026-01-13