Golang configuration library using spf13/viper — layered precedence (flag > env > file > KV > default), BindPFlag/BindPFlags, SetEnvPrefix + SetEnvKeyReplace...
---
name: golang-spf13-viper
description: "Golang configuration library using spf13/viper — layered precedence (flag > env > file > KV > default), BindPFlag/BindPFlags, SetEnvPrefix + SetEnvKeyReplacer + AutomaticEnv, ReadInConfig + ConfigFileNotFoundError, Unmarshal + mapstructure struct tags, Sub for sub-trees, WatchConfig + OnConfigChange for hot reload, viper.New() for test isolation, and remote KV integration. Apply when using or adopting spf13/viper, or when the codebase imports `github.com/spf13/viper`. For CLI command structure alongside viper, see the `samber/cc-skills-golang@golang-spf13-cobra` skill. For general CLI architecture, see `samber/cc-skills-golang@golang-cli`."
user-invocable: true
license: MIT
compatibility: Designed for Claude Code or similar AI coding agents, and for projects using Golang.
metadata:
author: samber
version: "1.0.1"
openclaw:
emoji: "🔧"
homepage: https://github.com/samber/cc-skills-golang
requires:
bins:
- go
install: []
skill-library-version: "1.21.0"
allowed-tools: Read Edit Write Glob Grep Bash(go:*) Bash(golangci-lint:*) Bash(git:*) Agent WebFetch mcp__context7__resolve-library-id mcp__context7__query-docs
---
**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:**
- [pkg.go.dev/github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper)
- [github.com/spf13/viper](https://github.com/spf13/viper)
This skill is not exhaustive. Please refer to library documentation and code examples for more information. Context7 can help as a discoverability platform.
```bash
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. Viper has no user-facing surface; it is purely a key-value resolver. Use cobra alone for flag-only CLIs; viper alone for config-file daemons; both when you need both, binding 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
```go
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:
```go
// ✓ 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):
```go
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`:
```go
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
```go
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) { /* re-apply changed values */ })
```
`WatchConfig` uses fsnotify and watches inodes. Editors that write atomically via rename (vim, neovim) replace the inode — 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:
```go
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
1. **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`).
2. **Handle `ConfigFileNotFoundError` gracefully** — a missing config file should not crash a service that runs with only flags and env vars.
3. **Always use `mapstructure` tags on config structs** — implicit mapping silently misses nested and underscore-named fields.
4. **Use `viper.New()` in tests, never the global** — the global accumulates state across test runs; per-test instances are isolated.
5. **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>.
don't have the plugin yet? install it then click "run inline in claude" again.
added explicit 7-step procedure with inputs/outputs, decision points for conditional config file handling and flag binding timing, output contract specifying the resolved config struct format, and outcome signal with concrete test commands to verify precedence order and env key replacement.
use spf13/viper to build layered configuration systems in go. viper resolves config keys by walking a fixed precedence pipeline: explicit Set() calls beat flags beat env vars beat config files beat remote KV beat defaults. apply this skill when adopting viper, integrating it with cobra CLI commands, or debugging why a config value isn't what you expect. viper has no user-facing surface , it's purely a key-value resolver that powers config-driven CLIs, daemons, and services.
go get github.com/spf13/viper@latestno external APIs or OAuth required. all inputs are local files, env vars, or in-process flag objects.
input: a map of key-value pairs you want as fallback values output: viper instance ready to receive higher-priority sources
import "github.com/spf13/viper"
v := viper.New() // use viper.New() for isolation; avoid global viper in production
v.SetDefault("port", 8080)
v.SetDefault("database.host", "localhost")
v.SetDefault("database.max_conn", 10)
defaults are the lowest priority. always set them so missing config never causes a panic on a key lookup.
input: config file path, format (yaml/toml/json), and graceful-not-found handling output: config file values merged into viper, or skipped if file doesn't exist
v.SetConfigName("config")
v.AddConfigPath("$HOME/.myapp")
v.AddConfigPath("/etc/myapp")
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
var notFound *viper.ConfigFileNotFoundError
if !errors.As(err, ¬Found) {
return fmt.Errorf("reading config: %w", err)
}
// file not found is ok , we'll use flags/env/defaults instead
}
always handle ConfigFileNotFoundError separately. a missing config file should not crash a service that runs on flags and env vars alone.
input: optional env var prefix (string), key replacer (for dots-to-underscores), and AutomaticEnv flag output: all three env settings bound together so nested keys like database.host resolve to MYAPP_DATABASE_HOST
v.SetEnvPrefix("MYAPP") // PORT → MYAPP_PORT
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) // database.host → MYAPP_DATABASE_HOST
v.AutomaticEnv() // read any env var matching the bound keys
critical: all three must be wired together at initialization time. missing SetEnvKeyReplacer causes dots to stay as dots in the env key lookup, so database.host looks for MYAPP_DATABASE.HOST (which doesn't exist on most systems) instead of MYAPP_DATABASE_HOST.
input: a pflag.FlagSet (usually from cobra Command.Flags() or Command.PersistentFlags()), and a viper instance output: each flag value synced into viper so viper.Get("key") returns the flag value if set
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var rootCmd = &cobra.Command{
Use: "myapp",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return viper.BindPFlags(cmd.PersistentFlags())
},
}
func init() {
rootCmd.PersistentFlags().Int("port", 8080, "listen port")
rootCmd.PersistentFlags().String("database-host", "localhost", "db host")
// bindings happen in PersistentPreRunE, which runs before any RunE
}
bind flags in PersistentPreRunE, not in RunE. cobra runs PersistentPreRunE before RunE, so bindings set in RunE are missed. flag names like database-host are automatically converted to database.host by viper.
alternatively, bind a single flag:
viper.BindPFlag("port", cmd.Flags().Lookup("port"))
input: a key name (string), and the expected type (int, string, etc.) output: the resolved value from the highest-priority source, or the default if no source set it
port := v.GetInt("port")
dbHost := v.GetString("database.host")
dbMaxConn := v.GetInt("database.max_conn")
viper walks the precedence pipeline and returns the first value it finds. if no source set the key, it returns the default.
input: a pointer to a struct with mapstructure tags, a viper instance output: all resolved config values written into the struct fields
type Config struct {
Port int `mapstructure:"port"`
Database struct {
Host string `mapstructure:"host"`
MaxConn int `mapstructure:"max_conn"`
} `mapstructure:"database"`
}
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
return fmt.Errorf("unmarshaling config: %w", err)
}
always use explicit mapstructure tags on every field. implicit field mapping is fragile for nested structs and underscore-named keys.
alternatively, unmarshal a single subtree:
var dbCfg struct {
Host string `mapstructure:"host"`
MaxConn int `mapstructure:"max_conn"`
}
if err := v.UnmarshalKey("database", &dbCfg); err != nil {
return fmt.Errorf("unmarshaling database config: %w", err)
}
prefer UnmarshalKey over Sub + Unmarshal because Sub returns nil if the key doesn't exist (requiring an extra nil-check), while UnmarshalKey handles that gracefully.
input: a viper instance with a config file, and a callback function output: the callback fires whenever the config file changes on disk
v.WatchConfig()
v.OnConfigChange(func(e fsnotify.Event) {
fmt.Println("config changed, reloading...")
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
fmt.Fprintf(os.Stderr, "reload failed: %v\n", err)
return
}
applyNewConfig(cfg)
})
WatchConfig uses fsnotify to watch file inodes. caveat: editors like vim and neovim that write via atomic rename (write-to-temp, then rename) replace the inode, so the callback may not fire. test hot-reload with echo >> config.yaml (append), not editor saves.
if the config file is optional (most cases):
use errors.As(err, &viper.ConfigFileNotFoundError) to distinguish "file not found" from "file found but unparseable". only return an error if the file was found but invalid. if the file is missing, proceed with env vars, flags, and defaults.
if you need nested config sub-trees:
prefer UnmarshalKey("database", &dbCfg) over Sub("database").Unmarshal(&dbCfg). Sub returns nil if the key doesn't exist, forcing a nil-check; UnmarshalKey avoids that risk.
if using cobra (CLI with subcommands):
bind flags in PersistentPreRunE, not in init() or RunE. cobra parses flags before PersistentPreRunE runs, so bindings set in PersistentPreRunE are available for config resolution. bindings set in RunE are too late because PersistentPreRunE already ran.
if the env key contains nested dots (e.g., database.host):
always call SetEnvKeyReplacer(strings.NewReplacer(".", "_")) before AutomaticEnv(). without it, viper looks for DATABASE.HOST (dot preserved) instead of DATABASE_HOST, which doesn't exist on most systems.
if running tests:
use viper.New() per test case, never the global viper instance. the global accumulates state across test runs, causing flaky ordering and cross-test contamination. each test should have its own isolated viper instance.
if the config key might not exist in any source:
always check with viper.IsSet("key") before calling Get() methods. alternatively, use the default value set in step 1 to guarantee a sensible fallback.
on successful execution, the skill produces:
a populated config struct or map containing all resolved values from the precedence pipeline (flag > env > file > KV > default). example:
type Config struct {
Port int
Database struct {
Host string
MaxConn int
}
}
with fields populated from the highest-priority source for each key.
no errors from config loading, parsing, or unmarshaling (unless the config file is invalid, in which case the error is explicit and the caller can decide how to handle it).
all viper methods callable on the instance without panics (e.g., Get(), GetInt(), GetString(), IsSet(), etc.).
the output is in-memory. viper does not write to files or external services (unless you explicitly call viper.WriteConfig() or similar, which is rare).
you know the skill worked when:
viper.Get() and typed accessors (GetInt, GetString, etc.) return the expected values without panics or unexpected defaults. test with: fmt.Println(v.GetString("database.host")) , you should see the value from the highest-priority source (flag, then env, then file, then default).
flags override env vars, and env vars override config files, in the correct order. test by setting the same key in a file, env var, and flag, then verify viper returns the flag value (highest priority). remove the flag and verify it returns the env var. remove the env var and verify it returns the file value.
env vars with dots in the key name resolve correctly (e.g., setting MYAPP_DATABASE_HOST=localhost is read as database.host). test with: os.Setenv("MYAPP_DATABASE_HOST", "myhost"); v.SetEnvPrefix("MYAPP"); v.SetEnvKeyReplacer(strings.NewReplacer(".", "_")); v.AutomaticEnv(); fmt.Println(v.GetString("database.host")) , should print "myhost".
config file parsing doesn't crash if the file is missing, and the program runs with env vars or defaults instead. test by removing the config file and confirming the service or CLI still starts.
unmarshaling into a struct populates all fields, including nested ones, without silent misses. test with: var cfg Config; v.Unmarshal(&cfg); fmt.Printf("%+v\n", cfg) , all fields should be populated.
hot-reload callbacks fire when the config file changes (for long-running services). test with: v.WatchConfig(); v.OnConfigChange(func(e fsnotify.Event) { fmt.Println("changed") }); echo "new_key: value" >> config.yaml , the callback should print "changed".
each test case has isolated viper state (for unit tests). test by creating two test functions that each call viper.New(), setting different values, and confirming they don't interfere.