The configuration problem
You build a command-line tool that works perfectly on your laptop. You push it to a shared server. It fails because the server expects a different port, a different log level, and a different database path. You hardcode the values, commit, and realize you just broke your local workflow. The next iteration requires environment variables, then a YAML file, then a flag override for CI pipelines. Managing configuration manually quickly turns into a tangled web of os.Getenv, flag.StringVar, and manual precedence checks.
Go gives you the standard library flag package, which handles basic parsing. It does not handle configuration files, environment variable fallbacks, or type coercion. That gap is why the Go ecosystem settled on a specific pairing: Cobra for command routing and Viper for configuration resolution. They are not part of the standard library, but they follow Go conventions closely enough that they feel native once you understand their separation of concerns.
How Cobra and Viper split the work
Cobra manages the command tree. It parses os.Args, matches subcommands, validates flags, and prints help text. It knows nothing about configuration files or environment variables. Viper manages the values. It reads defaults, checks environment variables, loads a configuration file, and binds command-line flags to internal keys. It knows nothing about subcommands or help text.
Think of Cobra as the steering wheel and pedals. It translates driver input into directional commands. Think of Viper as the dashboard computer. It reads the owner's manual, checks the ambient temperature, listens to the driver's adjustments, and decides what engine parameters to apply. The two systems communicate through a single bridge: flag binding. When you tell Viper to watch a Cobra flag, Viper intercepts the parsed value and stores it in its internal cache. The rest of your program reads from Viper, never from Cobra directly.
This separation keeps your code testable. You can mock Viper's values in unit tests without constructing fake command-line arguments. You can test Cobra's routing without worrying about YAML parsing. The boundary is clean.
Minimal setup
Every Cobra+Viper project starts with the same skeleton. The init function runs before main, which makes it the natural place to configure Viper and bind flags.
package main
import (
"fmt"
"log"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// rootCmd represents the base command when called without any subcommands.
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "A simple CLI tool with configuration",
Run: func(cmd *cobra.Command, args []string) {
// Read the final resolved value from Viper, not from flags directly.
port := viper.GetInt("port")
fmt.Printf("Starting on port %d\n", port)
},
}
// init runs before main and sets up configuration precedence.
func init() {
// Tell Viper to look for environment variables prefixed with MYAPP_.
viper.SetEnvPrefix("MYAPP")
// Enable automatic binding of environment variables to config keys.
viper.AutomaticEnv()
// Set a sensible default so the program runs without a config file.
viper.SetDefault("port", 8080)
// Bind the CLI flag to the internal "port" key so Viper tracks overrides.
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().String("config", "", "path to config file")
_ = viper.BindPFlag("config", rootCmd.PersistentFlags().Lookup("config"))
}
// initConfig reads the config file if it exists and binds it to Viper.
func initConfig() {
configPath := viper.GetString("config")
if configPath != "" {
viper.SetConfigFile(configPath)
} else {
// Search standard directories for config.yaml.
viper.AddConfigPath(".")
viper.SetConfigName("config")
viper.SetConfigType("yaml")
}
// ReadInConfig returns an error only if the file exists but is malformed.
if err := viper.ReadInConfig(); err == nil {
// Config loaded successfully. Viper caches the values.
}
}
func main() {
// Execute parses os.Args and calls the matching Run function.
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
The init function runs first. It registers the environment prefix, enables automatic environment binding, sets defaults, and attaches a custom initConfig hook to Cobra. When Cobra parses the command line, it calls initConfig before running the command. initConfig locates the file, reads it, and lets Viper cache the values. The Run function simply asks Viper for the final value.
The precedence chain
Viper does not evaluate configuration at the moment you call viper.Get. It builds a priority stack and resolves values lazily. The order is strict:
- Explicit flags passed on the command line.
- Environment variables matching the key or prefix.
- Values from the loaded configuration file.
- Defaults set with
viper.SetDefault.
If you pass --port 9090, Viper stores that value. If you set MYAPP_PORT=7070 in the environment, Viper checks it next. If config.yaml contains port: 6060, Viper reads it. If nothing else exists, Viper falls back to 8080. The first match wins. This chain removes the need for manual if os.Getenv != "" checks scattered across your codebase.
Viper caches the resolved value after the first access. Subsequent calls to viper.GetInt("port") return the cached result instantly. This design keeps runtime overhead near zero, which matters when your CLI spawns hundreds of short-lived operations.
The precedence chain is deterministic. Trust it. Do not rebuild it manually.
Realistic command structure
Production CLIs rarely have a single command. They split concerns into subcommands, each with its own flags and configuration needs. Viper handles this gracefully because it uses a flat key namespace. You can scope keys by command name or keep them global.
package main
import (
"fmt"
"log"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
// rootCmd is the entry point for the application.
var rootCmd = &cobra.Command{
Use: "toolkit",
Short: "A multi-command CLI with shared configuration",
}
// serverCmd starts a long-running service.
var serverCmd = &cobra.Command{
Use: "serve",
Short: "Start the HTTP server",
Run: func(cmd *cobra.Command, args []string) {
// Typed getters prevent runtime panics from wrong types.
addr := viper.GetString("server.address")
port := viper.GetInt("server.port")
fmt.Printf("Listening on %s:%d\n", addr, port)
},
}
// init registers subcommands and binds their flags to Viper keys.
func init() {
viper.SetEnvPrefix("TOOLKIT")
viper.AutomaticEnv()
viper.SetDefault("server.address", "127.0.0.1")
viper.SetDefault("server.port", 8080)
// Bind server flags to namespaced Viper keys.
serverCmd.Flags().String("addr", "", "bind address")
serverCmd.Flags().Int("port", 0, "bind port")
_ = viper.BindPFlag("server.address", serverCmd.Flags().Lookup("addr"))
_ = viper.BindPFlag("server.port", serverCmd.Flags().Lookup("port"))
rootCmd.AddCommand(serverCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
Namespacing keys with dots (server.port) keeps the configuration flat while preserving logical grouping. Viper treats the dot as a literal character in the key string, so viper.GetString("server.port") works without extra parsing. The BindPFlag calls connect Cobra's parsed values to Viper's cache. When serverCmd runs, Viper resolves server.port using the same precedence chain.
Notice the typed getters: viper.GetString and viper.GetInt. Viper's Get method returns an any type. Casting any to a specific type at runtime panics if the types mismatch. Typed getters fail gracefully or return zero values, which keeps your program from crashing on misconfigured files.
Use typed getters. Avoid viper.Get in production code.
Common traps and compiler feedback
The most frequent mistake is treating viper.ReadInConfig() as a hard failure. Viper returns viper.ConfigFileNotFoundError when the file does not exist. This is expected behavior, not an error condition. If you log it as fatal, your CLI refuses to run without a config file, even when defaults and environment variables are sufficient.
Check the error type explicitly. If it is viper.ConfigFileNotFoundError, ignore it. If it is any other error, the file exists but contains invalid YAML or JSON. That is a real problem.
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// Malformed config file. Exit with a clear message.
log.Fatalf("config file error: %v", err)
}
}
Another trap is binding flags after Cobra has already parsed arguments. init runs before main, but cobra.OnInitialize runs during command execution. If you bind flags in main or after Execute() is called, Viper never sees the parsed values. Keep all binding in init or in the OnInitialize hook.
The compiler will catch type mismatches early. If you pass a string where an integer is expected, you get cannot use "8080" (untyped string constant) as int value in argument. If you forget to import viper or cobra, the compiler rejects the file with undefined: viper. If you declare a variable and never use it, you get imported and not used or declared and not used. Go's compiler is strict about unused symbols. The community accepts this friction because it eliminates dead code and accidental dependencies.
Run gofmt on every save. Do not argue about indentation or brace placement. The tool enforces a single style across the entire ecosystem, which makes reading other people's CLIs frictionless.
Configuration is plumbing. Keep it predictable and let the precedence chain do the heavy lifting.
When to reach for this stack
Use Cobra and Viper when your CLI needs subcommands, environment variable overrides, and file-based configuration without writing manual precedence logic. Reach for the standard library flag package when you only need a few flags, want zero dependencies, and are comfortable parsing environment variables yourself. Pick urfave/cli when you prefer a single package that bundles command parsing and configuration, and you do not mind a slightly heavier dependency footprint. Stick to raw os.Args slicing when you are writing a one-off script that will never be shared or versioned.