Viper and Config Layering
Viper and Config Layering
Overview
Viper is often paired with Cobra for layered config: files, env, flags, defaults. It is powerful and also easy to overuse. Prefer the stdlib merge from chapter 305 until you feel real pain—then Viper (or a thinner alternative) earns its dependency.
When Viper helps
| Helps | Overkill |
|---|---|
| Many file formats (YAML/TOML/JSON) | Single JSON + few flags |
| Automatic env binding | Three getenv calls |
| Live watch (rare for CLIs) | One-shot tools |
| Large apps with many keys | Tiny scripts |
Install
go get github.com/spf13/viper@latestMinimal Viper + Cobra
import (
"strings"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var cfgFile string
var rootCmd = &cobra.Command{
Use: "mytool",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initConfig()
},
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
rootCmd.PersistentFlags().String("api-url", "", "API URL")
_ = viper.BindPFlag("api_url", rootCmd.PersistentFlags().Lookup("api-url"))
}
func initConfig() error {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
if dir, err := os.UserConfigDir(); err == nil {
viper.AddConfigPath(filepath.Join(dir, "mytool"))
}
}
viper.SetEnvPrefix("MYTOOL")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
viper.AutomaticEnv()
viper.SetDefault("api_url", "http://127.0.0.1:8080")
viper.SetDefault("timeout", "10s")
if err := viper.ReadInConfig(); err != nil {
// ignore not found; surface parse errors
var nf viper.ConfigFileNotFoundError
if !errors.As(err, &nf) {
// also handle "file not found" for SetConfigFile path
if !os.IsNotExist(err) {
return err
}
}
}
return nil
}// in a command:
url := viper.GetString("api_url")Precedence (Viper)
Typical order (highest last wins when binding flags correctly):
defaults → config file → env → flags
Confirm with your version’s docs; always document for users.
Example config.yaml
api_url: https://api.example.com
timeout: 15s
verbose: trueUnmarshal into struct
type Config struct {
APIURL string `mapstructure:"api_url"`
Timeout string `mapstructure:"timeout"`
Verbose bool `mapstructure:"verbose"`
}
var c Config
if err := viper.Unmarshal(&c); err != nil {
return err
}Bind env explicitly
_ = viper.BindEnv("api_url", "MYTOOL_API_URL")Useful when AutomaticEnv naming is awkward.
Pitfalls
- Global viper — the package-level instance is convenient and hard to test. Prefer
viper.New()per app in larger codebases. - Type confusion — timeouts as strings vs durations; parse explicitly.
- Flag default vs viper default — set one source of truth.
- Sensitive values — don’t dump full viper settings into logs.
Isolated Viper for tests
func newViper() *viper.Viper {
v := viper.New()
v.SetDefault("api_url", "http://127.0.0.1:8080")
return v
}Pass *viper.Viper on your App struct instead of using globals.
Lighter alternatives
| Approach | Notes |
|---|---|
| Stdlib JSON + env (ch 305) | Zero dep, enough for most tools |
caarlos0/env |
Struct tags for env only |
knadh/koanf |
Modular, explicit providers |
pelletier/go-toml / yaml.v3 |
Parse files yourself |
Stdlib-first checklist before Viper
- ≤ 15 config keys
- JSON or single format is fine
- Flags + env + one file path cover deploys
If all true, stay stdlib.
Rules of thumb
| Do | Don’t |
|---|---|
| Document precedence | Surprise users with silent file overrides |
viper.New() when testing |
Global state across parallel tests |
| Redact secrets in debug dumps | Log entire config maps |
| Fail on corrupt files | Ignore parse errors |
Try next
- Bind
--api-urlto viper and override withMYTOOL_API_URL. - Load YAML from
UserConfigDir. - Refactor to
viper.New()injected into commands.