A Go environment-variable loader that treats env vars the way encoding/json treats JSON: a struct gets populated via tags, with first-class support for live reload and pluggable sources — OS environment, in-memory maps, ordered chains, and .env files (via the separate go-rotini/dotenv package).
This package is used as the default environment-variable support package for rotini.
- One-line happy path:
env.Load(&cfg)is enough for most programs encoding/json-shaped API: single comma-separatedenv:"NAME,opt1,opt2"tag, genericLoadTo[T]convenience, a configurableLoader(theDecoder-style entry point) for advanced cases- Aggregated multi-error reporting with per-field attribution (
*MultiError,FormatError) - Principled
Sourceinterface:OSEnv(),Map,Multi(...)built in;.envparsing via go-rotini/dotenv; remote secret backends in their own modules — all equal citizens context.Contextsupport:LoadContextthreads the context to mutators and toUnmarshalerContext/ValidatorContexthooks- Atomic, lock-free reads on reload via
Live[T]andsync/atomic.Pointer - Per-field
immutableenforcement: reload candidates that change a marked field are rejected (old/new values redacted forsecretfields) - Secret-redacting output (
String(),Describe,PrintUsage,Markdown,Encode) plus aSecret[T]wrapper integrating withslog.LogValuer - Variable expansion on tagged values (
expandtag):$VAR,${VAR},${VAR-default},${VAR:-default},${VAR:?error}— references resolve against the source chain, used verbatim (noWithPrefixapplied) - Built-in support for
time.Duration,time.Time,*time.Location,url.URL,net.IP,*net.IPNet,regexp.Regexp,[]byte(raw, base64, hex), plusencoding.TextUnmarshalerandencoding.BinaryUnmarshalerinterop - Mutator chain for value rewriting (resolving
secret://indirections, fetching from a vault) and observation (logging, metrics) Validate() errorlifecycle hook for cross-field rules- Auto-generated help text (
PrintUsage) and Markdown documentation (Markdown) from a tagged struct - Reverse direction:
Encode/EncodeFilewrite a struct out asKEY=VALUElines suitable for child-process env or.env.distfiles - Zero non-stdlib runtime dependencies; no sub-packages
go get github.com/go-rotini/envRequires Go 1.26 or later.
package main
import (
"fmt"
"log"
"time"
"github.com/go-rotini/env"
)
type Config struct {
Port int `env:"PORT,default=8080" envDesc:"HTTP listen port"`
DBURL string `env:"DATABASE_URL,required" envDesc:"Primary database DSN"`
Timeout time.Duration `env:"TIMEOUT,default=30s" envDesc:"Request timeout"`
Hosts []string `env:"HOSTS,separator=;" envDesc:"Allowed host list"`
APIKey string `env:"API_KEY,required,secret" envDesc:"Upstream API credential"`
}
func main() {
// One-shot load (default Loader, OSEnv source, no prefix).
var cfg Config
if err := env.Load(&cfg); err != nil {
log.Fatal(env.FormatError(err))
}
fmt.Printf("%+v\n", cfg)
// Generic convenience.
cfg2, err := env.LoadTo[Config]()
if err != nil {
log.Fatal(err)
}
fmt.Printf("%+v\n", cfg2)
}.env parsing lives in the separate github.com/go-rotini/dotenv
package (which has no dependencies). Its NewSource returns a value usable directly
as a Source — dotenv doesn't import this package; the method sets just line up:
import (
"github.com/go-rotini/env"
"github.com/go-rotini/dotenv"
)
src, err := dotenv.NewSource(".env", dotenv.WithExpand())
if err != nil { log.Fatal(err) }
// OSEnv() listed first wins over the .env file (the standard Compose / Heroku
// precedence model). Reverse the order to make the file authoritative.
loader := env.New(env.WithSource(env.OSEnv(), src))
var cfg Config
if err := loader.Load(&cfg); err != nil {
log.Fatal(env.FormatError(err))
}Live[T] wraps the loader in an atomic.Pointer[T]-backed handle. Reads are
O(1) and lock-free; readers always observe a complete, validated snapshot.
Reload is driven by any Source that also implements env.Watcher; sources
that don't are polled at the interval set by WithPollInterval, or read once
and never re-read if that option is absent.
live, err := env.NewLive[Config](env.WithSource(env.OSEnv(), changingSrc))
if err != nil { log.Fatal(err) }
defer live.Close()
// Hot-path read.
go func() {
for {
cfg := live.Get() // *Config — never nil after NewLive succeeds
serve(cfg)
}
}()
// Optional event observation.
go func() {
for ev := range live.Events() {
if ev.Err != nil {
log.Printf("reload failed: %v", ev.Err)
continue
}
log.Printf("reload: changed=%v", ev.Changed)
}
}()For automatic reload on .env (or yaml/toml/jsonc) file edits, use a live
source built on github.com/go-rotini/fs's
file watcher — it debounces editor save bursts and handles the "atomic save"
pattern (write-temp-then-rename) by watching the parent directory and filtering
by filename. The reload pipeline parses + validates a fresh candidate before
swapping; a failed candidate retains the previous value and emits an error
event.
Describe / PrintUsage / Markdown walk the same field pipeline as Load
but emit documentation instead of populating a struct. Wire them into a
--env-help flag or a docs-generation step:
// Print a column-aligned help table to stderr.
_ = env.PrintUsage(os.Stderr, &Config{})
// Generate a GitHub-Flavored Markdown reference.
_ = env.Markdown(os.Stdout, &Config{})Encode writes a struct out as KEY=VALUE lines — useful for child-process
env, audit snapshots, or generating an example .env.dist file:
// Default redacts secrets as ***.
_ = env.Encode(os.Stdout, &cfg)
// For child-process exec.
buf := &bytes.Buffer{}
_ = env.Encode(buf, &cfg, env.WithEncodeIncludeSecrets(true))
cmd.Env = strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")The package-level Encode / EncodeFile use the defaults (env tag,
SCREAMING_SNAKE_CASE, no prefix). If you built the loader with WithTagName,
WithFieldNameTransform, or WithPrefix, use the loader methods
loader.Encode / loader.EncodeFile so the output round-trips through
loader.Load.
For lossless .env editing (preserving comments and original quoting), use
github.com/go-rotini/dotenv's Parse →
(*File).Set → (*File).Marshal instead — Encode is a struct-to-text path
and does not preserve formatting.
Source chains are explicit and ordered — the first source to return ok=true
wins. The cleanest mental model is "process env wins over .env file unless
told otherwise" — list OSEnv() first:
loader := env.New(env.WithSource(
env.OSEnv(), // wins by default
mustDotenv(".env.local"), // dev overrides (dotenv.NewSource)
mustDotenv(".env"), // baseline (dotenv.NewSource)
))(mustDotenv here is a thin wrapper around
github.com/go-rotini/dotenv's
NewSource that panics on error.) WithSourceAppend and WithSourcePrepend
adjust an existing chain without replacing it.
Implement Validate() error on the config type for cross-field rules. The
loader invokes it after every field has populated and reports its return value
through the standard MultiError aggregation:
func (c *Config) Validate() error {
if c.Port < 1024 && os.Geteuid() != 0 {
return fmt.Errorf("PORT %d requires root", c.Port)
}
return nil
}For validation that needs the request context, implement Validate(ctx context.Context) error
instead (the ValidatorContext interface) — the loader passes the context given to
LoadContext (or context.Background() for plain Load). A type implements one or the other,
not both. On reload, the validator runs against the new candidate; failure retains the previous
value.
Likewise, a custom field type can implement UnmarshalEnv(ctx context.Context, s string) error
(UnmarshalerContext) to receive the context during decoding, instead of the plain
UnmarshalEnv(s string) error (Unmarshaler).
Full API reference is available on pkg.go.dev.
See CONTRIBUTING.md for guidelines on how to contribute to this project.
This project follows a code of conduct to ensure a welcoming community. See CODE_OF_CONDUCT.md.
To report a vulnerability, see SECURITY.md.
This project is licensed under the MIT License. See LICENSE for details.