How to Use Viper for Configuration in Go

Use Viper to manage configuration by setting up a config file (like YAML or JSON), enabling environment variable overrides, and binding flags to your config keys.

Configuration that adapts to the environment

You write a Go service. It works perfectly on your laptop. You deploy it to a container, and the database connection fails. You check the logs and realize the port is hardcoded to 8080, but the container runtime assigned 9090. You rebuild the binary, push a new image, and redeploy. This cycle repeats for every environment change. Hardcoding configuration couples your code to a specific deployment. It forces rebuilds for trivial changes. It breaks the separation between code and environment.

Viper solves this by treating configuration as a stack of sources. It reads defaults, checks for a configuration file, looks at environment variables, and finally respects command-line flags. The last source wins. If a key exists in multiple layers, the value from the highest layer overwrites the lower ones. This precedence model lets you ship an app with sensible defaults, allow operators to tweak settings via a file, and let the deployment system override critical values like secrets or ports without touching the code.

The precedence stack

Viper does not just read a file. It merges multiple inputs into a single namespace. The order of precedence is strict. Defaults sit at the bottom. A configuration file sits above defaults. Environment variables sit above the file. Command-line flags sit at the top. When you call viper.Get("port"), Viper checks the flags first. If the flag is not set, it checks the environment. If the environment variable is missing, it checks the config file. If the file lacks the key, it falls back to the default.

This design supports a "convention over configuration" workflow. You can ship an app that works out of the box with defaults. An admin can drop a config.yaml to customize behavior. The CI/CD pipeline can inject secrets via environment variables. The operator can override a single setting at runtime with a flag. Viper handles the merging automatically.

Minimal example

Here is the simplest way to load a configuration file and read a value. This example sets up Viper to look for a config.yaml file, reads it, and retrieves a port number.

package main

import (
	"fmt"
	"log"

	"github.com/spf13/viper"
)

func main() {
	// tells Viper to look for a file named 'config' and try common extensions like .yaml
	viper.SetConfigName("config")
	// searches the current directory first, then /etc/myapp, then the user's home config dir
	viper.AddConfigPath(".")
	viper.AddConfigPath("/etc/myapp")
	viper.AddConfigPath("$HOME/.myapp")

	// loads the config file; returns an error only if the file exists but is invalid
	if err := viper.ReadInConfig(); err != nil {
		// Viper returns a specific error type when the file is missing
		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
			// file not found is acceptable; Viper will use defaults or env vars
			log.Println("Config file not found, proceeding with defaults")
		} else {
			// any other error means the file is malformed; fail fast
			log.Fatalf("Error reading config file: %v", err)
		}
	}

	// retrieves the value; returns 0 if the key does not exist
	port := viper.GetInt("server.port")
	fmt.Printf("Server port: %d\n", port)
}

How the loading mechanism works

SetConfigName defines the base name of the configuration file. Viper automatically appends common extensions like .yaml, .yml, .json, .toml, .hcl, and .env. You do not need to specify the extension. SetConfigType is only required if you use a non-standard extension or read from a string.

AddConfigPath defines the directories where Viper searches for the config file. Viper checks these paths in the order you add them. It stops at the first path that contains a matching file. This allows you to prioritize local development configs over system-wide configs.

ReadInConfig performs the file search and parsing. It returns a viper.ConfigFileNotFoundError if no file is found in any of the paths. This is not a fatal error. Viper is designed to work without a config file. You can catch this error and let the app continue with defaults or environment variables. Any other error indicates a parsing failure. A malformed YAML file should crash the app immediately. Hiding syntax errors leads to subtle bugs later.

Viper is case-insensitive for keys. server.port, Server.Port, and SERVER.PORT all refer to the same key. This reduces friction when mapping configuration keys to Go struct fields. Go conventions use CamelCase for exported fields. YAML conventions often use snake_case. Viper bridges this gap automatically.

Realistic example with structs and environment variables

In production, you rarely access config values individually. You unmarshal the entire configuration into a struct. This gives you type safety and IDE support. Viper uses the mapstructure library to map flat configuration keys to nested Go structs. You must add mapstructure tags to your struct fields. Viper does not use json or yaml tags by default.

Environment variables provide a powerful override mechanism. AutomaticEnv tells Viper to check environment variables for every key. SetEnvPrefix adds a prefix to all environment variable names. SetEnvKeyReplacer transforms the config key format to match environment variable conventions. Environment variables cannot contain dots. The replacer converts dots to underscores.

Here is a complete configuration setup. It defines a struct, loads a YAML file, enables environment variable overrides, and unmarshals the result.

package main

import (
	"fmt"
	"log"
	"strings"

	"github.com/spf13/viper"
)

// Config holds the application configuration.
// mapstructure tags ensure Viper maps flat keys to nested fields correctly.
type Config struct {
	Server struct {
		Port int    `mapstructure:"port"`
		Host string `mapstructure:"host"`
	} `mapstructure:"server"`
	Debug bool `mapstructure:"debug"`
}

func main() {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")

	// enables environment variable overrides for all keys
	viper.AutomaticEnv()
	// adds 'APP' prefix to env vars; APP_SERVER_PORT overrides server.port
	viper.SetEnvPrefix("APP")
	// converts dots to underscores so server.port matches APP_SERVER_PORT
	viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

	if err := viper.ReadInConfig(); err != nil {
		if _, ok := err.(viper.ConfigFileNotFoundError); ok {
			log.Println("No config file found")
		} else {
			log.Fatalf("Config error: %v", err)
		}
	}

	var cfg Config
	// unmarshals the merged config into the struct; fails if types mismatch
	if err := viper.Unmarshal(&cfg); err != nil {
		log.Fatalf("Cannot decode config into struct: %v", err)
	}

	fmt.Printf("Host: %s, Port: %d, Debug: %v\n", cfg.Server.Host, cfg.Server.Port, cfg.Debug)
}

The corresponding config.yaml file uses nested keys. Viper flattens these keys internally. server.port maps to the Port field via the mapstructure tag. The AutomaticEnv call allows APP_SERVER_PORT=9090 to override the value in the YAML file. The SetEnvKeyReplacer ensures the dot in server.port becomes an underscore in APP_SERVER_PORT. Without the replacer, Viper would look for an environment variable named APP_SERVER.PORT, which is invalid on most systems.

Viper is often paired with the Cobra CLI library. Cobra handles command-line flags. Viper binds those flags to its config namespace using viper.BindPFlag. This creates a seamless integration where flags, env vars, and files all feed into the same configuration object. The ecosystem convention is to use Cobra for CLI structure and Viper for configuration. They share the same author and are designed to work together.

Pitfalls and runtime errors

Unexported struct fields break unmarshaling. Viper uses reflection to set struct fields. Go reflection cannot modify unexported fields. If your struct has a lowercase field, Viper silently skips it. The field retains its zero value. This leads to confusing behavior where config values appear to be missing.

The compiler does not catch this. The runtime simply ignores the field. If you unmarshal into a struct with unexported fields, you get no error. The fields remain empty. Always use exported fields for configuration structs. If you need private fields, compute them from public fields in an initialization function.

AutomaticEnv can impact performance. When enabled, Viper checks every configuration key against the environment variables. In a large configuration with hundreds of keys, this adds overhead to every Get call. If performance is critical, disable AutomaticEnv and bind specific keys to environment variables using viper.BindEnv. This limits the environment variable lookup to only the keys you explicitly register.

Type coercion is automatic but strict. Viper tries to convert values to the requested type. viper.GetBool("debug") works for true, false, 1, 0, yes, no. It fails for other strings. If the config file contains debug: "enabled", GetBool returns false. Be explicit in your configuration files. Use boolean literals for booleans, integers for integers, and strings for strings. Do not rely on Viper to guess types for ambiguous values.

The compiler rejects the program with undefined: viper if you forget to import the package. It complains with cannot use cfg (variable of type Config) as *Config value in argument if you pass a value instead of a pointer to Unmarshal. Unmarshal requires a pointer to modify the struct.

Decision matrix

Use Viper when you need a robust configuration system that supports files, environment variables, and flags with automatic merging and precedence.

Reach for the standard library flag package when your tool is a simple CLI utility that only needs a few arguments and does not require file-based configuration or environment variable overrides.

Pick raw os.Getenv when you are writing a library that must remain dependency-free and only needs to read a handful of environment variables without any fallback logic.

Use koanf when you want a lighter alternative to Viper with a more modular design, though Viper remains the ecosystem standard and has broader community support.

Trust the precedence stack. Defaults provide safety. Files provide customization. Environment variables provide deployment flexibility. Flags provide runtime control. Viper handles the complexity so you can focus on the application logic.

Where to go next