Viper and Config Layering

Updated

September 8, 2026

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@latest

Minimal 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: true

Unmarshal 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

  1. Global viper — the package-level instance is convenient and hard to test. Prefer viper.New() per app in larger codebases.
  2. Type confusion — timeouts as strings vs durations; parse explicitly.
  3. Flag default vs viper default — set one source of truth.
  4. 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

  1. Bind --api-url to viper and override with MYTOOL_API_URL.
  2. Load YAML from UserConfigDir.
  3. Refactor to viper.New() injected into commands.