Persona: You are a Go engineer who treats configuration as a layered system. Flag beats env beats file beats default — and you bind every key so all four layers stay reachable through one API.
Using spf13/viper for layered configuration in Go
Viper resolves configuration values from multiple sources in a fixed precedence order. It has no user-facing surface — it doesn't define commands or flags. Its job is to answer "what is the value of key X right now?" by walking its source layers from highest to lowest priority.
Official Resources:
This skill is not exhaustive — refer to library documentation and code examples for more information:
- For Go package docs, symbols, versions, importers, and known vulnerabilities, → See
samber/cc-skills-golang@golang-pkg-go-dev skill (godig), preferred over Context7 for Go package facts.
- To navigate this library's usage in your own code (definitions, call sites, diagnostics), → See
samber/cc-skills-golang@golang-gopls skill (gopls).
- Context7 remains a fallback for docs not indexed on pkg.go.dev.
go get github.com/spf13/viper@latest
Viper vs. cobra
Cobra owns the command tree — subcommands, flags, arg validation, completions. Viper owns configuration resolution — it answers "what is the value of key X?" by walking its source layers, with no user-facing surface of its own: it is purely a key-value resolver.
- Cobra alone — flag-only CLIs.
- Viper alone — config-file daemons.
- Both — bind flags at
PersistentPreRunE via BindPFlag.
→ See samber/cc-skills-golang@golang-spf13-cobra for the cobra side of this integration.
The precedence pipeline
Viper resolves a key by walking sources in this order (first set value wins):
1. explicit Set() — viper.Set("key", val) highest priority
2. flag — bound pflag.Flag
3. env var — BindEnv / AutomaticEnv
4. config file — ReadInConfig / MergeInConfig
5. KV remote — etcd / Consul
6. default — viper.SetDefault("key", val) lowest priority
This pipeline is fixed and cannot be reordered. Understanding it prevents most viper bugs: a key that "should" come from a config file may be shadowed by an env var or a flag with a default value.
Sources and config files
viper.SetConfigName("config")
viper.AddConfigPath("$HOME/.myapp")
if err := viper.ReadInConfig(); err != nil {
var notFound *viper.ConfigFileNotFoundError
if !errors.As(err, ¬Found) {
return fmt.Errorf("reading config: %w", err) // propagate real errors only
}
}
ConfigFileNotFoundError must be handled gracefully — config files are usually optional. An unhandled error from a missing file crashes programs that are perfectly valid when run with only flags or env vars.
For supported formats (JSON, TOML, YAML, HCL, INI, properties), MergeInConfig, and remote KV, see [sources-and-formats.md](references/sources-and-formats.md).
Env binding and key replacers
This is the highest-bug-density area in viper. All three settings must be wired together — missing any one breaks nested key resolution:
// ✓ Good — all three wired together at startup
viper.SetEnvPrefix("MYAPP") // prevent collisions: PORT → MYAPP_PORT
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // database.host → MYAPP_DATABASE_HOST
viper.AutomaticEnv()
// ✗ Bad — without SetEnvKeyReplacer, viper looks for MYAPP_DATABASE.HOST (dot preserved)
For BindEnv, AllowEmptyEnv, and env-vs-default interaction, see [binding-and-env.md](references/binding-and-env.md).
Flag binding (the cobra seam)
Bind cobra flags to viper in init() or PersistentPreRunE — never in RunE (config loading in PersistentPreRunE already ran before RunE, so bindings set in RunE are missed):
func init() {
rootCmd.PersistentFlags().Int("port", 8080, "listen port")
viper.BindPFlag("port", rootCmd.PersistentFlags().Lookup("port"))
// viper.BindPFlags(cmd.Flags()) — bind an entire FlagSet at once
}
For AllowEmptyEnv and flag/env interaction details, see [binding-and-env.md](references/binding-and-env.md).
Unmarshaling into structs
viper.Unmarshal maps the resolved configuration into a struct using mapstructure:
type Config struct {
Port int `mapstructure:"port"`
Database struct {
MaxConn int `mapstructure:"max_conn"` // explicit tag: mapstructure won't convert underscore→camelCase
} `mapstructure:"database"`
}
var cfg Config
viper.Unmarshal(&cfg)
Always use mapstructure tags — implicit mapping is fragile for nested structs and underscore-named fields. Prefer UnmarshalKey("database", &dbCfg) over Sub("database").Unmarshal — it avoids the nil-check Sub requires when the key is missing.
For time.Duration / net.IP / slice decoders and custom DecodeHook registration, see [unmarshal.md](references/unmarshal.md).
Sub-trees
viper.Sub("database") returns a new *viper.Viper scoped to the prefix, or nil if the key does not exist — always nil-check before calling methods on the result. Prefer UnmarshalKey("database", &dbCfg) which avoids the nil risk entirely.
Hot reload
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) { /* re-apply changed values */ })
WatchConfig uses fsnotify and watches inodes, so editors that write atomically via rename (vim, neovim) replace the inode and the callback may not fire. Test hot-reload with echo >> config.yaml, not editor saves. For race-safe reload patterns, see [watch-and-reload.md](references/watch-and-reload.md).
Test isolation
Never use the global viper in tests — state leaks across test cases. Use viper.New() per test so each instance is isolated:
v := viper.New()
v.SetConfigFile("testdata/config.yaml")
require.NoError(t, v.ReadInConfig())
For t.Setenv interactions and Reset() limitations, see [testing-and-isolation.md](references/testing-and-isolation.md).
Best Practices
- Set prefix + key replacer + AutomaticEnv together — missing any one causes nested env keys to silently not resolve (
database.host → DATABASE.HOST instead of DATABASE_HOST).
- Handle
ConfigFileNotFoundError gracefully — a missing config file should not crash a service that runs with only flags and env vars.
- Always use
mapstructure tags on config structs — implicit mapping silently misses nested and underscore-named fields.
- Use
viper.New() in tests, never the global — the global accumulates state across test runs; per-test instances are isolated.
- Bind flags before
Execute() — binding in RunE is too late; cobra parses flags before RunE runs.
Common Mistakes
| Mistake |
Why it fails |
Fix |
AutomaticEnv without SetEnvKeyReplacer |
database.host looks for MYAPP_DATABASE.HOST (dot preserved) — never matches |
Add SetEnvKeyReplacer(strings.NewReplacer(".", "_")) before AutomaticEnv |
No mapstructure tags on struct fields |
Silently misses nested and underscore-named fields |
Add mapstructure:"key_name" to every field |
| Using global viper in tests |
State from one test contaminates the next, causing flaky ordering |
Create viper.New() per test |
Missing ConfigFileNotFoundError check |
Missing config file crashes a service that should run on flags/env alone |
errors.As(err, ¬Found) — only propagate non-not-found errors |
Further Reading
- [sources-and-formats.md](references/sources-and-formats.md) — supported file formats, multi-path search, MergeInConfig, remote KV (etcd/Consul)
- [binding-and-env.md](references/binding-and-env.md) — BindEnv, AutomaticEnv, SetEnvPrefix, SetEnvKeyReplacer, AllowEmptyEnv, timing rules
- [unmarshal.md](references/unmarshal.md) — Unmarshal, UnmarshalKey, mapstructure tags, custom DecodeHooks (Duration, IP, slice)
- [watch-and-reload.md](references/watch-and-reload.md) — WatchConfig, OnConfigChange, fsnotify caveats, atomic-rename trap, race-safe patterns
- [testing-and-isolation.md](references/testing-and-isolation.md) — viper.New() per test, t.Setenv interactions, Reset() limitations, snapshot/restore
Cross-References
- → See
samber/cc-skills-golang@golang-cli skill for general CLI architecture — project layout, exit codes, signal handling, cobra+viper integration
- → See
samber/cc-skills-golang@golang-spf13-cobra skill for the cobra side of this integration (flag definition and binding)
- → See
samber/cc-skills-golang@golang-testing skill for general Go testing patterns
If you encounter a bug or unexpected behavior in spf13/viper, open an issue at <https://github.com/spf13/viper/issues>.