Reliable Application Configuration from Environment Variables in Go
Environment variables are a convenient way to configure deployed Go services, but calling os.Getenv throughout an application makes configuration difficult to validate and test.
A stronger pattern is to load configuration once at startup, parse it into typed fields, validate all invariants, and pass the resulting value to the components that need it.
The examples below use only the Go standard library and work with modern supported Go releases.
Keep configuration in a typed struct
Suppose a service needs a listen address, request timeout, and optional log level:
package config
import "time"
type Config struct {
Address string
RequestTimeout time.Duration
LogLevel string
}Once configuration is typed, the rest of the program does not need to know whether a value came from an environment variable, a test fixture, or another source.
Distinguish missing values from empty values
os.Getenv returns an empty string both when a variable is unset and when it is explicitly set to an empty string. os.LookupEnv distinguishes those cases.
For required values:
func requiredEnv(name string) (string, error) {
value, ok := os.LookupEnv(name)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%s is required", name)
}
return value, nil
}Whether an explicit empty string should be accepted is a product decision. Make that decision in one place instead of relying on accidental behavior.
Parse values at startup
Do not pass raw strings deeper into the application. Parse durations, integers, URLs, and booleans while loading configuration.
func Load() (Config, error) {
address, err := requiredEnv("APP_ADDRESS")
if err != nil {
return Config{}, err
}
timeoutText := envOrDefault("APP_REQUEST_TIMEOUT", "5s")
timeout, err := time.ParseDuration(timeoutText)
if err != nil {
return Config{}, fmt.Errorf(
"APP_REQUEST_TIMEOUT %q: %w",
timeoutText,
err,
)
}
level := envOrDefault("APP_LOG_LEVEL", "info")
cfg := Config{
Address: address,
RequestTimeout: timeout,
LogLevel: level,
}
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func envOrDefault(name, fallback string) string {
if value, ok := os.LookupEnv(name); ok {
return value
}
return fallback
}A value such as 250ms, 5s, or 2m is clearer than inventing an undocumented integer unit for a timeout.
Validate relationships between fields
Parsing proves that a value has the right syntax. Validation proves that the configuration makes sense for the application.
func (c Config) Validate() error {
if c.RequestTimeout <= 0 {
return errors.New("APP_REQUEST_TIMEOUT must be positive")
}
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
return fmt.Errorf("unsupported APP_LOG_LEVEL %q", c.LogLevel)
}
return nil
}For larger configurations, collect multiple validation errors when practical so an operator can fix several mistakes in one restart cycle.
Fail early instead of silently repairing bad input
Defaults are appropriate for genuinely optional settings. They are dangerous when used to hide invalid user input.
For example, if APP_REQUEST_TIMEOUT=abc, do not silently fall back to five seconds. The operator attempted to configure a value, so startup should report that it is malformed.
This distinction is useful:
- unset optional value → apply a documented default;
- set but invalid value → return an error;
- missing required value → return an error.
Keep secrets out of logs
Configuration errors should identify the variable but should not echo secret values.
Avoid messages such as:
invalid DATABASE_PASSWORD="super-secret-value"Prefer:
DATABASE_PASSWORD is requiredThe same rule applies to debug dumps. If a configuration struct contains credentials, tokens, private keys, or connection strings with passwords, do not print the whole struct with %+v.
Make configuration easy to test
Go tests can set environment variables for the duration of a test with t.Setenv:
func TestLoad(t *testing.T) {
t.Setenv("APP_ADDRESS", ":8080")
t.Setenv("APP_REQUEST_TIMEOUT", "750ms")
t.Setenv("APP_LOG_LEVEL", "debug")
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.RequestTimeout != 750*time.Millisecond {
t.Fatalf("timeout = %s", cfg.RequestTimeout)
}
}Use table-driven tests for malformed durations, missing required variables, unsupported enum-like values, and boundary conditions.
If tests can run in parallel, remember that environment variables are process-wide state. Avoid parallel tests that mutate the same variable names.
Pass configuration explicitly
After loading configuration in main, pass the typed values into constructors:
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatal(err)
}
server := &http.Server{
Addr: cfg.Address,
ReadHeaderTimeout: cfg.RequestTimeout,
}
log.Fatal(server.ListenAndServe())
}This keeps business packages independent of global environment state and makes them easier to test with ordinary values.
Common pitfalls
Reading environment variables on every request
Configuration usually changes through a new deployment or process restart. Re-reading process environment during request handling adds hidden global dependencies without providing a reliable dynamic configuration mechanism.
Using empty strings as universal defaults
An empty string may be a valid value, a missing value, or an error depending on the field. Encode that meaning explicitly.
Mixing parsing with application logic
If every package calls strconv.Atoi or time.ParseDuration, validation rules become inconsistent. Centralize loading at the application boundary.
Embedding production credentials in source code
Source examples and defaults should never contain real credentials. Use placeholders such as YOUR_API_KEY only when a value is necessary to illustrate configuration.
A useful configuration boundary
The goal is not to build a configuration framework. For many Go services, a small loader built on os.LookupEnv, the standard parsing packages, and explicit validation is enough.
Load once, fail clearly, keep values typed, and make secret handling deliberate. That gives the rest of the program a stable configuration contract instead of a collection of global strings.