Problem Being Solved
Simplifying environment variable parsing in Go applications
Go's standard library gives you the tools to read environment variables, but it leaves the tedious work of parsing, validating, and converting values entirely to you. This page explains why that manual approach doesn't scale and how envconfig addresses the problem by mapping environment variables directly onto typed Go structs.
The manual approach and its costs
When you read environment variables in Go without a library, your code typically looks like a series of os.Getenv calls followed by manual type conversions and error checks:
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
portNum, err := strconv.Atoi(port)
if err != nil {
log.Fatalf("invalid PORT: %v", err)
}
timeout := os.Getenv("REQUEST_TIMEOUT")
if timeout == "" {
timeout = "30s"
}
timeoutDur, err := time.ParseDuration(timeout)
if err != nil {
log.Fatalf("invalid REQUEST_TIMEOUT: %v", err)
}
This pattern has real costs as your application grows:
- Repetition. Every variable needs the same boilerplate: read, check for empty, convert, handle error.
- Scattered defaults. Default values are buried inside conditional blocks throughout your code, making them hard to find or audit.
- No single source of truth. There is no one place that describes all the environment variables your application accepts.
- Manual validation. Required variables, acceptable ranges, and format constraints all require custom code.
- Type unsafety. Everything starts as a string. Forgetting a conversion means a runtime surprise, not a compile-time error.
What a better solution looks like
The core insight behind envconfig is that your configuration has a natural shape — a struct — and environment variables are just a source of values to populate it. If you declare that shape once, the library can handle reading, converting, validating, and applying defaults automatically.
With envconfig, you define a struct whose fields describe each configuration value, annotate them with struct tags, and call a single function to populate the struct from your environment:
type Config struct {
Port int `env:"PORT,default=8080"`
RequestTimeout time.Duration `env:"REQUEST_TIMEOUT,default=30s"`
DatabaseURL string `env:"DATABASE_URL,required"`
}
This declaration replaces the scattered os.Getenv calls. It tells you — and anyone reading your code — exactly which environment variables the application consumes, what type each one should be, and what happens when a value is absent.
Why struct tags are the right abstraction
Struct tags keep the configuration contract co-located with the data it describes. When you add a new field, you add it in one place. When you need to understand what PORT controls, you look at the struct, not at a chain of if statements. This locality makes configuration easier to review, document, and maintain.
The approach also leverages Go's type system. Because each field has a concrete type (int, time.Duration, bool, and so on), invalid values are caught during the processing step — before your application logic ever runs.
Manual parsing vs. envconfig side by side
The following two snippets configure an HTTP server with the same three values. The first uses the standard library alone; the second uses envconfig.
Without envconfig:
package main
import (
"log"
"os"
"strconv"
"time"
)
func main() {
host := os.Getenv("HOST")
if host == "" {
host = "localhost"
}
portStr := os.Getenv("PORT")
if portStr == "" {
portStr = "8080"
}
port, err := strconv.Atoi(portStr)
if err != nil {
log.Fatalf("invalid PORT: %v", err)
}
timeoutStr := os.Getenv("TIMEOUT")
if timeoutStr == "" {
timeoutStr = "30s"
}
timeout, err := time.ParseDuration(timeoutStr)
if err != nil {
log.Fatalf("invalid TIMEOUT: %v", err)
}
log.Printf("host=%s port=%d timeout=%s", host, port, timeout)
}
With envconfig:
package main
import (
"context"
"log"
"github.com/sethvargo/go-envconfig"
)
type Config struct {
Host string `env:"HOST,default=localhost"`
Port int `env:"PORT,default=8080"`
Timeout time.Duration `env:"TIMEOUT,default=30s"`
}
func main() {
var c Config
if err := envconfig.Process(context.Background(), &c); err != nil {
log.Fatal(err)
}
log.Printf("host=%s port=%d timeout=%s", c.Host, c.Port, c.Timeout)
}
Expected output (with no environment variables set, defaults apply):
2024/01/15 12:00:00 host=localhost port=8080 timeout=30s
The envconfig version is shorter, but more importantly, the struct serves as living documentation of the application's environment contract.
- Defining a configuration struct — how to declare fields, choose types, and apply struct tags for common scenarios
- Processing environment variables — how
envconfig.Processworks and what happens when variables are missing or malformed - Setting defaults and required fields — controlling what envconfig does when a variable is absent
- Implementing a custom decoder — extending envconfig to handle types it does not support out of the box