The configuration problem
You deploy a Go service to a container. The database host changes between staging and production. The port shifts. A feature flag flips. Hardcoding these values means rebuilding the binary for every environment. Command-line flags work locally but break in container orchestrators. Environment variables solve the deployment problem, but parsing them manually turns your startup code into a wall of os.Getenv calls and string-to-type conversions. envconfig removes that friction by mapping environment variables directly to a Go struct.
How struct tags bridge the gap
Struct tags are metadata attached to fields. They look like backtick-quoted strings inside the struct definition. Think of them as shipping labels on a moving box. The environment is a warehouse full of labeled crates. Your struct is a packing list. The tags tell the runtime exactly which crate belongs in which slot. Under the hood, the library uses reflection to inspect your struct, read the tags, look up the matching environment variables, convert the strings to the correct Go types, and assign them to the fields. You get type safety without writing the conversion logic yourself.
Reflection in Go is not magic. It is a set of standard library functions that let you inspect types at runtime. The library walks through each field of your struct, checks if it has an env tag, and matches it against the environment. If the types align, the assignment happens automatically. If they do not, the library returns an error before your application starts processing requests. This keeps configuration failures fast and visible.
Struct tags are just strings. The compiler does not validate their syntax. You get the flexibility to name them anything, but the community sticks to env for environment mapping. Consistency matters more than cleverness here.
The minimal setup
Here is the simplest way to load configuration from the environment. The code defines a struct, attaches tags, and calls the processing function.
package main
import (
"fmt"
"log"
"github.com/kelseyhightower/envconfig"
)
// Config holds application settings mapped from environment variables.
type Config struct {
DatabaseHost string `env:"DATABASE_HOST"`
DatabasePort int `env:"DATABASE_PORT"`
Debug bool `env:"DEBUG"`
}
func main() {
var cfg Config
// Empty prefix means the library looks for exact variable names.
if err := envconfig.Process("", &cfg); err != nil {
log.Fatal(err)
}
fmt.Printf("Host: %s, Port: %d, Debug: %t\n", cfg.DatabaseHost, cfg.DatabasePort, cfg.Debug)
}
Set the variables before running the program. The shell exports them into the process environment.
export DATABASE_HOST=localhost
export DATABASE_PORT=5432
export DEBUG=true
go run main.go
The Process function reads the environment at runtime. It matches DATABASE_HOST to the DatabaseHost field, converts the string to an integer for DatabasePort, and parses the boolean for Debug. If a variable is missing, the field keeps its zero value. Strings become empty strings, integers become zero, and booleans become false. The program continues running unless you explicitly check for missing values.
Environment variables are always strings. The library handles the conversion, but you still need to understand how Go interprets truthy and falsy values. true, 1, yes, and on all parse to true. Everything else becomes false. This behavior is consistent across the standard library and third-party parsers.
Real-world configuration
Production services rarely rely on zero values. You need defaults, required fields, and namespace scoping. The library supports all three through tag options and the prefix argument.
package main
import (
"log"
"github.com/kelseyhightower/envconfig"
)
// ServiceConfig groups settings for a single microservice.
type ServiceConfig struct {
// Default fallback when the variable is unset.
Timeout int `env:"TIMEOUT" envDefault:"30"`
// Fails fast if the variable is missing from the environment.
APIKey string `env:"API_KEY, required"`
// Comma-separated values parse into a slice automatically.
AllowedOrigins []string `env:"ALLOWED_ORIGINS"`
}
func LoadConfig() (ServiceConfig, error) {
var cfg ServiceConfig
// Prefix scopes variables to prevent collisions in shared environments.
if err := envconfig.Process("MYAPP", &cfg); err != nil {
return cfg, err
}
return cfg, nil
}
The prefix argument changes how the library searches the environment. With MYAPP, it looks for MYAPP_TIMEOUT, MYAPP_API_KEY, and MYAPP_ALLOWED_ORIGINS. This prevents two services running in the same container from stepping on each other's variables. The envDefault tag provides a fallback value when the environment is silent. The required option flips the behavior: missing variables become hard errors instead of silent zero values.
Slices and maps parse from comma-separated strings. The library splits on commas and trims whitespace. Custom types work if they implement encoding.TextUnmarshaler. You get the same conversion pipeline without writing boilerplate.
Error handling in Go is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. You check the error immediately, return it, and let the caller decide how to fail. This pattern keeps configuration failures from hiding behind silent defaults.
Where things break
Reflection cannot touch unexported fields. If you name a field databaseHost instead of DatabaseHost, the library skips it entirely. The field stays at its zero value, and you get no warning. Exported fields must start with a capital letter. This is a language rule, not a library quirk. The compiler enforces visibility at the package boundary, and reflection follows the same rules.
Type mismatches return errors before your application starts. If you set DATABASE_PORT=abc, the library rejects the program with envconfig: error converting value "abc" to int for field DatabasePort. The error message tells you exactly which field failed and why. You do not need to guess.
Missing required variables also fail fast. The compiler complains with envconfig: required environment variable "API_KEY" not set if you forget to export it. This is intentional. Failing at startup is cheaper than failing under load.
Prefix collisions happen when two services share the same namespace. If you run envconfig.Process("APP", &cfg1) and envconfig.Process("APP", &cfg2), both structs compete for the same variables. The last call wins. Scope your prefixes to the service name or team name. Keep them unique.
Zero values are not always safe defaults. An empty string for a database host looks like a valid configuration until the connection fails. A zero port means localhost port zero, which the OS assigns randomly. A false debug flag hides logs when you need them most. Use required tags or explicit validation when zero values would cause silent failures.
The worst configuration bug is the one that never logs. Validate your struct after loading. Check that ports are in range, hosts are not empty, and slices contain expected values. Fail loudly.
When to reach for envconfig
Use envconfig when you need type-safe environment parsing without writing conversion logic. Use os.Getenv when you only need a single string value and want to avoid external dependencies. Use the flag package when your tool runs in a terminal and users expect command-line arguments with built-in help text. Use a configuration file library when your deployment targets on-premise servers that do not support environment variable injection. Use plain sequential code when you do not need external configuration: the simplest thing that works is usually the right thing.
Environment variables are the standard for containerized deployments. Struct tags keep your code readable. Reflection handles the heavy lifting. Validate early, fail fast, and never trust zero values.