Overview
envconf is a standalone Go module (github.com/gp-system/envconf): typed configuration loaded from environment variables into a Go struct, once, at process start, with an optional .env file for local development. It has two dependencies, caarlos0/env (the struct-tag parser) and joho/godotenv (.env file loading), and no gp-system-specific knowledge: it works the same way in any Go program. The gpsystem kit uses it as the single loading step every generated project runs in main(); see Configuration for how a project composes a dozen modules' worth of settings into one struct with it.
Install
go get github.com/gp-system/envconf@v0.1.0 # the kit itself uses this tag
go get github.com/gp-system/envconf@latest
Go 1.25+. Dependency footprint: caarlos0/env/v11 and joho/godotenv, nothing else.
Load, MustLoad, LoadPrefixed
func Load[T any]() (T, error)
func MustLoad[T any]() T
func LoadPrefixed[T any](prefix string) (T, error)
Load parses environment variables into a new T, following env:"..." struct tags, and returns the populated value or an error (a missing required variable, a value that doesn't parse into the field's type). MustLoad is the same, but panics instead of returning an error: the form you actually want at the top of main(), where there is nothing meaningful to do with a configuration error except fail fast and loudly.
type Config struct {
ListenAddr string `env:"LISTEN_ADDR" envDefault:":3000"`
DBHost string `env:"DB_HOST,required"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"5s"`
}
cfg := envconf.MustLoad[Config]()
LoadPrefixed is Load with every field's env variable additionally prefixed, useful when the same config shape needs to be loaded more than once under different names (see Recipes for a two-Valkey-connections example):
primary, err := envconf.LoadPrefixed[ValkeyConfig]("PRIMARY_") // PRIMARY_ADDR, PRIMARY_PASSWORD, ...
replica, err := envconf.LoadPrefixed[ValkeyConfig]("REPLICA_") // REPLICA_ADDR, REPLICA_PASSWORD, ...
Tags and defaults
The parsing itself is caarlos0/env's: envconf adds the .env loading step in front of it, nothing more. The tags that matter day to day:
| Tag | Meaning |
|---|---|
env:"NAME" | the environment variable to read |
env:"NAME,required" | fails Load/MustLoad if the variable is unset |
envDefault:"value" | used when the variable is unset (and not required) |
envPrefix:"DB_" | on a nested struct field, prefixes every one of its own tags |
envSeparator:"," | for slice fields, the separator between values |
Field types follow Go's usual parsing: string, int, bool, time.Duration, []string, and anything implementing encoding.TextUnmarshaler. A required field with no default and no value set is exactly the failure Load is supposed to catch before the process finishes booting, not on the first request that touches it.
.env loading
Before parsing, Load/MustLoad/LoadPrefixed load a .env file from the working directory via godotenv, best-effort: a missing .env file is not an error. Crucially, godotenv never overrides a variable that's already set in the process environment, so .env only fills in gaps, it never shadows a value the real environment already provided. That single rule is what lets the same Config struct behave correctly everywhere:
- Local development, with nothing exported in the shell: every value comes from
.env. - A docker-compose dev stack: the same
.envfile, service hostnames resolving to the compose network instead oflocalhost. - Staging/production: the real environment (systemd, a Kubernetes ConfigMap/Secret) sets variables directly;
.envis typically absent, and even if a stray one were present, the already-set variables win regardless.
This makes .env purely a developer convenience: nothing about how the config loads changes between environments, only what's providing the values.
Standalone example
package main
import (
"fmt"
"time"
"github.com/gp-system/envconf"
)
type Config struct {
ListenAddr string `env:"LISTEN_ADDR" envDefault:":3000"`
APIKey string `env:"API_KEY,required"`
Timeout time.Duration `env:"TIMEOUT" envDefault:"5s"`
}
func main() {
cfg := envconf.MustLoad[Config]()
fmt.Printf("listening on %s, timeout %s\n", cfg.ListenAddr, cfg.Timeout)
}
API_KEY=dev-secret
TIMEOUT=10s
Run it with no shell exports at all (API_KEY and TIMEOUT come from .env, LISTEN_ADDR falls back to its default), or set API_KEY in the real environment and delete .env entirely: the program behaves identically either way.
Related pages
- envconf: Recipes: composing a root config from module configs, prefixed multi-instance loads, testing with
t.Setenv. - Configuration: what a generated gpsystem project composes with
envconf, and the env-prefix conventions across modules. - Configuration reference: every env variable of every module, with defaults.