Configuration with viper & envconfig
Services and CLIs need configuration that changes per environment without recompilation.
Search across all documentation pages
Services and CLIs need configuration that changes per environment without recompilation.
spf13/viper merges files, flags, and environment variables with precedence rules; kelseyhightower/envconfig maps env vars directly into typed structs for smaller binaries.
Quick-reference recipe card - copy-paste ready.
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.AutomaticEnv()
viper.SetDefault("http.port", 8080)
_ = viper.ReadInConfig()
port := viper.GetInt("http.port")type Config struct {
Port int `envconfig:"PORT" default:"8080"`
DSN string `envconfig:"DATABASE_URL" required:"true"`
}
var cfg Config
envconfig.Process("", &cfg)When to reach for this:
os.Getenv scattered across packagespackage main
import (
"fmt"
"log"
"strings"
"github.com/spf13/viper"
)
type AppConfig struct {
HTTPPort int
LogLevel string
DSN string
}
func loadViper() AppConfig {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
viper.AddConfigPath(".")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv()
viper.SetDefault("http.port", 8080)
viper.SetDefault("log.level", "info")
if err := viper.ReadInConfig(); err != nil {
log.Printf("no config file: %v", err)
}
return AppConfig{
HTTPPort: viper.GetInt("http.port"),
LogLevel: viper.GetString("log.level"),
DSN: viper.GetString("database.dsn"),
}
}
func main() {
cfg := loadViper()
if cfg.DSN == "" {
log.Fatal("database.dsn or DATABASE_DSN required")
}
fmt.Printf("port=%d level=%s\n", cfg.HTTPPort, cfg.LogLevel)
}What this demonstrates:
AutomaticEnv and key replacermainAppConfig struct separate from viper's stringly APIrequired fields.fsnotify (viper) or process restart (simplest ops model).main or a config package; domain packages receive typed structs, not global viper calls.| Source | Typical use | Override strength |
|---|---|---|
Explicit Set in code | Tests | Highest when used |
Flags (viper.BindPFlags) | CLI overrides | High |
| Environment | K8s Secrets, 12-factor | High |
| Config file | Defaults per environment | Medium |
SetDefault | Safe fallbacks | Low |
| Tag | Effect |
|---|---|
envconfig:"PORT" | Env var name (with optional prefix) |
required:"true" | Process fails if unset |
default:"8080" | Value when env missing |
split_words:"true" | HTTP_PORT maps to HTTPPort |
// Prefer unmarshaling into a struct once viper keys stabilize
var cfg AppConfig
if err := viper.Unmarshal(&cfg); err != nil {
log.Fatal(err)
}info level only.viper.GetString hide dependencies. Fix: load in main, pass AppConfig structs.http.port vs HTTP_PORT breaks silently without SetEnvKeyReplacer. Fix: document env names in Helm and README tables.GetString checks. Fix: validate with explicit if cfg.DSN == "" or envconfig required.WatchConfig reloads mid-request. Fix: restart pods or gate reload behind atomic pointer swap plus drain.flag.Parse misses CLI overrides. Fix: parse flags first, then bind with viper.BindPFlags.| Alternative | Use When | Don't Use When |
|---|---|---|
flag + os.Getenv only | Two env vars and one port flag | Dozens of keys across files |
caarlos0/env | Struct tags without envconfig's age | You already standardized on envconfig |
koanf | Explicit merge layers without viper globals | Team knows viper and wants one tool |
| Helm values only | Config never local | Developers need offline go run |
Start with envconfig when all config comes from Kubernetes env and Secrets.
Add viper when you need YAML defaults for local dev and file-based feature flags.
Set env vars in t.Setenv, write temp YAML files, or build AppConfig literals in tests without touching global viper when possible.
Often yes for shared AppConfig structs; CLIs may add pflag bindings where services rely on env only.
That page covers flags and env patterns broadly; this page compares viper and envconfig libraries specifically.
Yes - load file defaults with viper, then overlay with envconfig for secrets - but prefer one loader to avoid precedence confusion.
Unmarshal into structs and run go-playground/validator or hand-rolled checks on ports, URLs, and durations.
Generate a table from struct tags in README, duplicate in Helm values.yaml comments, and fail startup with actionable error messages.
For single-binary CLIs with three flags, stdlib flag is simpler and avoids a dependency.
Store flag keys in viper files or a remote provider; keep defaults safe when the provider is unreachable.
Yes - one package owns loading, validation, and redacted String() for logs.
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).
Reviewed by Chris St. John·Last updated Jul 19, 2026