How to Use the cobra Library for CLI Configuration

Cli
Use spf13/cobra to define CLI commands and spf13/viper to handle configuration files and flags for Go applications.

The config merge problem

You built a CLI tool that analyzes log files. It works great when you pass every flag on the command line. Then you try to run it on a server. Typing --host db-prod --port 5432 --user admin --verbose every time gets old. You want a config file. You also want environment variables for the CI pipeline. And you still want flags to override everything for quick tests.

Go's standard library gives you flag, which handles flags well but stops there. You need a system that merges flags, config files, and environment variables without writing a parser from scratch. Writing the merge logic manually leads to nested checks: look at the flag, then check os.Getenv, then load a YAML file, then fall back to a default. It's tedious and error-prone. The Go ecosystem standard for this is a pair of libraries: cobra and viper.

Cobra and Viper work as a pair

Cobra handles the command structure, subcommands, and flags. Viper handles the configuration resolution. They are separate libraries that connect through a binding mechanism. Cobra parses the command line. Viper reads config files and environment variables. When you bind Cobra flags to Viper, Viper includes the parsed flags in its merge logic.

Think of Viper like a layered priority system. The bottom layer is defaults. The next layer is the config file. The next is environment variables. The top layer is command-line flags. When you ask for a value, Viper looks from the top down. The first match wins. Flags override env vars, which override the file, which overrides defaults. You don't write the merge logic. Viper does it.

Viper merges layers. Flags win. Defaults lose.

Minimal example

Here's the simplest setup: a CLI that reads a host value from flags, a config file, or a default. The code defines a root command, sets up Viper in init, and binds the flag so Viper can see it.

package main

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

var rootCmd = &cobra.Command{
	Use: "mycli",
	Run: func(cmd *cobra.Command, args []string) {
		// Bind flags so viper includes them in the merge.
		viper.BindPFlags(cmd.Flags())
		fmt.Println("Host:", viper.GetString("host"))
	},
}

func main() {
	rootCmd.Execute()
}

The init function runs before main. It configures Viper and registers the flag.

func init() {
	// Defaults act as the fallback layer.
	viper.SetDefault("host", "localhost")
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	// ReadInConfig returns an error if the file is missing.
	// Ignore it here to allow running without a file.
	viper.ReadInConfig()
	// Define the flag. Viper binds this via BindPFlags.
	rootCmd.Flags().String("host", "", "Database host")
}

Bind flags before reading values. Viper doesn't guess.

How the merge happens

When the program starts, init runs. Viper sets the default host to localhost. It looks for config.yaml in the current directory. If the file exists, Viper parses it. If the file contains host: prod-db, that value overrides the default. Then the flag is registered.

When the user runs mycli --host override, Cobra parses the flag. Inside Run, BindPFlags tells Viper to look at the parsed flags. Viper checks the flag first. It finds override. That wins.

If the user runs mycli with no flag, Viper checks the config file. If the file has host: prod-db, that wins. If the file is missing or empty, the default localhost wins.

Environment variables fit into this chain. You can bind an env var explicitly with viper.BindEnv("host", "APP_HOST"). Viper will check APP_HOST after flags but before the config file. You can also enable viper.AutomaticEnv(), which makes Viper check an env var matching the key name for every call. Automatic env binding is convenient but can cause collisions if env vars share names with config keys. Explicit binding is safer.

Viper reads the config file when you call ReadInConfig. It parses the file and stores the values in memory. If the file is missing, ReadInConfig returns an error. Viper doesn't crash. It just returns the error. You can ignore the error if a config file is optional. If the file exists but has invalid YAML, ReadInConfig returns an error. You should handle this. A silent failure means your tool runs with wrong values and you never know why.

Realistic example: database tool

A real CLI often needs multiple values and sensitive data. This example adds a port, user, and password. The password comes from an environment variable for security. The code also checks the error from Execute, which is standard practice.

package main

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

var rootCmd = &cobra.Command{
	Use: "dbtool",
	Run: func(cmd *cobra.Command, args []string) {
		viper.BindPFlags(cmd.Flags())
		viper.BindEnv("password", "DB_PASSWORD")
		host := viper.GetString("host")
		port := viper.GetInt("port")
		user := viper.GetString("user")
		fmt.Printf("Connecting to %s:%d as %s\n", host, port, user)
	},
}

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

The init function sets defaults, reads the config, and registers flags.

func init() {
	viper.SetDefault("host", "localhost")
	viper.SetDefault("port", 5432)
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	viper.ReadInConfig()
	rootCmd.Flags().String("host", "", "Database host")
	rootCmd.Flags().Int("port", 0, "Database port")
	rootCmd.Flags().String("user", "", "Database user")
	rootCmd.Flags().String("password", "", "Database password")
}

Check the error from Execute. Silent failures hide bugs.

Pitfalls and runtime behavior

Viper is lenient by design. If you call viper.GetString("missing_key"), you get an empty string. No panic. No error. This makes Viper easy to use but can hide typos. If you mistype a key, your code gets a default value instead of the config value. Validate your config explicitly. Unmarshal the config into a struct and check required fields.

type Config struct {
	Host string `mapstructure:"host"`
	Port int    `mapstructure:"port"`
}

var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
    // Handle error
}

Viper uses mapstructure tags to map config keys to struct fields. This gives you type safety and validation. If the config is missing a required field, your validation logic catches it.

Viper uses a global singleton by default. All calls to viper.GetString share the same state. This works for most CLIs. If you build a library that uses Viper, the global state can conflict with the host application. Use viper.New() to create an isolated instance. Pass the instance around. This avoids collisions.

Convention aside: Go code checks errors explicitly. Viper returns errors from ReadInConfig and Unmarshal. Handle them. Don't ignore errors silently. If the config is broken, fail fast. The user needs to know. Also, gofmt formats your code. Run it. Viper and Cobra are third-party libraries. They follow Go conventions. Use gofmt to keep your code consistent with the rest of the ecosystem.

Convention aside: Receiver naming doesn't apply here since we aren't defining methods on structs. But if you wrap Viper in a struct, name the receiver c or cfg. Not this or self.

Convention aside: Public names start with a capital letter. Viper keys are strings, so casing doesn't matter for visibility. However, struct tags for unmarshaling should match the config keys. Use mapstructure:"host" to map a struct field to a config key.

If you forget to import viper, the compiler rejects the program with undefined: viper. If you import it but don't use it, you get imported and not used. Go's compiler catches these immediately. Runtime errors are different. A YAML syntax error in the config file causes ReadInConfig to return an error. If you ignore it, the program runs with defaults. Always check the error from ReadInConfig in production code.

Viper is lenient. Validate your config explicitly.

Decision matrix

Use flag from the standard library when your CLI has only a few flags and no config files.

Use cobra and viper when you need subcommands, a config file, and environment variable support.

Use viper.New() when you need multiple independent configuration instances in the same process.

Use a custom parser when you need strict validation that Viper's loose merging doesn't provide.

Use spf13/pflag alone when you want flag parsing without the config file merging logic.

Pick the tool that matches your complexity. Don't import Viper for a single flag.

Where to go next