envconf

Overview

Typed environment configuration loading with struct tags.

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:

TagMeaning
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 .env file, service hostnames resolving to the compose network instead of localhost.
  • Staging/production: the real environment (systemd, a Kubernetes ConfigMap/Secret) sets variables directly; .env is 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

main.go
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)
}
.env
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.

  • 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.
Copyright © 2026