Parse environment variables into Go structs with minimal boilerplate and first-class support for complex data structures
- Field names just work (snake or camel)
- Nested structs automatically create prefixes
- No special tags needed
- Reuse the json/yaml/toml tags a struct already has, without adding env tags
- You declare which tags to match and in what order, so it stays deterministic
- Declare required fields, defaults and names at the call site with
WithField - Works on structs you don't own, where you can't add tags at all
- One string value per variable
- No need to encode complex data in environment values
- Maps:
SETTINGS_KEY=value - Arrays:
PORTS_0=8080 - Nested structs:
REDIS_HOST=localhost - Slice of structs:
SERVERS_0_HOST=host1 - Map of structs:
DATABASES_PRIMARY_HOST=host1
- Most features from popular libraries available
- No lock-in to specific tag formats, tags are all configurable
- Default settings are all configurable
- Parsers/decoders are all configurable
go get github.com/sethpollack/envcfgSet the following environment variables:
# string
export NAME=name
# integer
export PORT=8080
# float
export RATE=1.23
# boolean
export IS_ENABLED=true
# duration
export TIMEOUT=60s
# nested struct
export REDIS_HOST=localhost
export REDIS_PORT=6379
# delimited slice
export TAGS=tag1,tag2,tag3
# indexed slice
export PORTS_0=8080
export PORTS_1=9090
# slice of structs
export SERVERS_0_HOST=localhost1
export SERVERS_0_PORT=8080
export SERVERS_1_HOST=localhost2
export SERVERS_1_PORT=9090
# key-value map
export LABELS=key1:value1,key2:value2
# flat map
export SETTINGS_KEY1=1
export SETTINGS_KEY2=2
# map of structs
export DATABASES_PRIMARY_HOST=localhost1
export DATABASES_PRIMARY_PORT=6379
export DATABASES_SECONDARY_HOST=localhost2
export DATABASES_SECONDARY_PORT=6380Parse the environment variables into a struct:
package main
import (
"fmt"
"time"
"github.com/sethpollack/envcfg"
)
func main() {
type ServerConfig struct {
Host string
Port int
}
type Config struct {
Name string
Port int
Rate float64
IsEnabled bool
Timeout time.Duration
Redis ServerConfig
Tags []string
Ports []int
Servers []ServerConfig
Labels map[string]string
Settings map[string]int
Databases map[string]ServerConfig
}
cfg := Config{}
if err := envcfg.Parse(&cfg); err != nil {
panic(err)
}
fmt.Printf(`Config:
Name: %s
Port: %d
Rate: %.2f
IsEnabled: %t
Timeout: %s
Redis:
Host: %s
Port: %d
Tags: %v
Ports: %v
Servers: [
{Host: %s, Port: %d},
{Host: %s, Port: %d}
]
Labels: %v
Settings: %v
Databases: {
primary: {Host: %s, Port: %d},
secondary: {Host: %s, Port: %d}
}
`,
cfg.Name, cfg.Port, cfg.Rate, cfg.IsEnabled, cfg.Timeout,
cfg.Redis.Host, cfg.Redis.Port,
cfg.Tags, cfg.Ports,
cfg.Servers[0].Host, cfg.Servers[0].Port,
cfg.Servers[1].Host, cfg.Servers[1].Port,
cfg.Labels, cfg.Settings,
cfg.Databases["primary"].Host, cfg.Databases["primary"].Port,
cfg.Databases["secondary"].Host, cfg.Databases["secondary"].Port,
)
}Results:
Config:
Name: name
Port: 8080
Rate: 1.23
IsEnabled: true
Timeout: 1m0s
Redis:
Host: localhost
Port: 6379
Tags: [tag1 tag2 tag3]
Ports: [8080 9090]
Servers: [
{Host: localhost1, Port: 8080},
{Host: localhost2, Port: 9090}
]
Labels: map[key1:value1 key2:value2]
Settings: map[key1:1 key2:2]
Databases: {
primary: {Host: localhost1, Port: 6379},
secondary: {Host: localhost2, Port: 6380}
}Tip
The example above demonstrates the three supported syntaxes for both slices and maps:
- Slices:
- delimited
TAGS=tag1,tag2,tag3 - indexed
PORTS_0=8080 - struct
SERVERS_0_HOST=localhost
- delimited
- Maps:
- key-value pairs
LABELS=key1:value1 - flat keys
SETTINGS_KEY1=1 - struct
DATABASES_PRIMARY_HOST=localhost
- key-value pairs
stringint,int8,int16,int32,int64uint,uint8,uint16,uint32,uint64boolfloat32,float64time.Durationstructsslicesmaps
Note
Type support can be extended using the WithKindParser and WithTypeParser options.
Indexes are discovered by scanning the environment, so a gap leaves a zero value rather than truncating or renumbering the rest:
export SERVERS_0_HOST=a
export SERVERS_2_HOST=c
# -> []Server{{Host: "a"}, {}, {Host: "c"}}A slice is sized to its largest index, so the type you pick decides whether the
gaps are materialized. Use map[int]T when you want only the indexes you set:
export SERVERS_0_HOST=a
export SERVERS_5_HOST=fServers []Server // [{a} {} {} {} {} {f}] length 6
Servers map[int]Server // map[0:{a} 5:{f}] length 2Setting both the delimited and the indexed form of the same field is an error, since there is no way to tell which one was meant:
export TAGS=a,b
export TAGS_0=z
# -> both delimited and indexed values found: TagsNote
Map keys are lowercased, so DATABASES_PRIMARY_HOST becomes the key primary.
Keys that need other casing aren't expressible.
envcfg.Decoderflag.Valueencoding.TextUnmarshalerencoding.BinaryUnmarshaler
Note
Decoder support can be extended using the WithDecoder option.
Every tag below has an equivalent call-site option, so tags are optional. See Field Overrides if you'd rather keep configuration out of your structs, or if the struct isn't yours to edit.
| Name | Description | Default | Example Tag | Example Option |
|---|---|---|---|---|
default |
Default value when environment variable is not set | - | default:"8080" |
env:",default=8080" |
required |
Mark field as required | false |
required:"true" |
env:",required" |
notempty |
Ensure value is not empty | false |
notempty:"true" |
env:",notempty" |
expand |
Expand environment variables in value | false |
expand:"true" |
env:",expand" |
file |
Load value from file | false |
file:"true" |
env:",file" |
delim |
Delimiter for array values | , |
delim:";" |
env:",delim=;" |
sep |
Separator for map key-value pairs | : |
sep:"=" |
env:",sep=" |
init |
Initialize nil pointers | vars |
init:"always" |
env:",init=always" |
ignore |
Ignore field | false |
ignore:"true" |
env:",ignore" or env:"-" |
decodeunset |
Decode unset environment variables | false |
decodeunset:"true" |
env:",decodeunset" |
Warning
When setting default values for slices, avoid using the comma as it conflicts with tag parsing. Either use a different delimiter or set array values using environment variables:
type Config struct {
Tags []string `env:"TAGS,default=tag1,tag2"` // This will fail!
}
// Do this instead:
type Config struct {
Tags []string `env:"TAGS,delim=;,default=tag1;tag2"` // Use a different delimiter
}vars- Initialize when values are present with the exception of structs that only have default values. (default)any- Same asvars, but also initialize structs that only have default values.always- Always initializenever- Never initialize
A self referential type only has a finite result when initialization is driven by values, since the data is what says where to stop:
type Node struct {
Name string
Next *Node
}export ROOT_NAME=l0
export ROOT_NEXT_NAME=l1
export ROOT_NEXT_NEXT_NAME=l2
# -> three levels, then Next is nilany and always do not depend on values, so on a recursive type they have no
finite result and are reported rather than truncated:
cannot initialize recursive type: Root.Next.Next: init=always on Node,
set init=vars or init=never on this field
Opt the recursive field back to vars to keep the rest of your configuration on
always. The mode carries down the cycle, so you only declare it once:
envcfg.Parse(&cfg,
envcfg.WithInitAlways(),
envcfg.WithField("Root", envcfg.WithField("Next", envcfg.InitVars())),
)Tip
All defaults and tag names can be customized using the With* options. See Configuration Options for more details.
By default envcfg matches on the env tag, then the struct field name, then the
snake case field name, in that order. The first match wins.
os.Setenv("CUSTOM_URL", "value") // Matches env tag
os.Setenv("DATABASEURL", "value") // Matches struct field
os.Setenv("DATABASE_URL", "value") // Matches snake-case
type Config struct {
DatabaseURL string `env:"custom_url"`
}Use WithMatch to choose the matchers yourself. They are tried in the order you
list them, so precedence is always explicit.
| Matcher | Matches on |
|---|---|
Tag(name) |
The value of the named struct tag |
Field() |
The struct field name |
Snake() |
The snake case struct field name |
AnyTag() |
Every remaining struct tag, in sorted tag name order |
This is how you consume structs from other libraries without adding env tags to
them, which matters most when the struct isn't yours to edit:
type Config struct {
DatabaseURL string `json:"db_url"`
DataSource string `yaml:"data_source"`
}
envcfg.Parse(&cfg, envcfg.WithMatch(
envcfg.Tag("json"),
envcfg.Tag("yaml"),
envcfg.Snake(),
))AnyTag() matches whatever tags happen to be present, for when you don't know them
up front. Tags that carry behavior rather than names (required, default, ...) are
never matched on.
// try the env tag first, then anything else on the field
envcfg.Parse(&cfg, envcfg.WithMatch(envcfg.Tag("env"), envcfg.AnyTag()))Tip
All environment variable matching is case insensitive.
Anything a struct tag can say, WithField can say at the call site instead. This
keeps the configuration in one readable place and works on structs you can't edit.
type Config struct {
DatabaseURL string
Port int
Timeout time.Duration
}
envcfg.Parse(&cfg,
envcfg.WithField("DatabaseURL", envcfg.Required(), envcfg.NotEmpty()),
envcfg.WithField("Port", envcfg.Default("8080")),
envcfg.WithField("Timeout", envcfg.Env("TIMEOUT_SECONDS")),
)WithFields takes them as a map when you'd rather read the whole contract as a table:
envcfg.Parse(&cfg, envcfg.WithFields(map[string][]envcfg.FieldOption{
"DatabaseURL": {envcfg.Required(), envcfg.NotEmpty()},
"Port": {envcfg.Default("8080")},
"Timeout": {envcfg.Env("TIMEOUT_SECONDS")},
}))| Option | Description | Equivalent tag |
|---|---|---|
Env(names...) |
Variable name, relative to the enclosing scope; extra names are fallbacks | env |
RootEnv(name) |
Absolute variable name, ignoring the enclosing prefix | - |
Default(value) |
Value when the variable is not set | default |
Required() |
Fail when the variable is not found | required |
NotEmpty() |
Fail when the variable is set but empty | notempty |
Expand() |
Expand variables in the value | expand |
File() |
Read the value from the file the variable points to | file |
Delim(delim) |
Delimiter for array values | delim |
Sep(sep) |
Separator for map key-value pairs | sep |
Ignore() |
Skip the field | ignore |
DecodeUnset() |
Decode even when unset | decodeunset |
InitVars() / InitAny() / InitAlways() / InitNever() |
Nil pointer initialization | init |
A field path that doesn't exist is an error rather than a silent no-op:
no such field: "DatabseURL", did you mean "DatabaseURL"?
WithField nests inside itself, so you can group a struct's fields without writing
dotted paths. Names inside a nested block are relative to the enclosing field, which
is what makes collections work without spelling out the index:
type Config struct {
Servers []Server // SERVERS_0_HOSTNAME, SERVERS_1_HOSTNAME, ...
}
envcfg.Parse(&cfg,
envcfg.WithField("Servers",
envcfg.WithField("Host", envcfg.Env("HOSTNAME")),
),
)Use RootEnv when a nested field should read a top-level variable instead of one
under the accumulated prefix:
type Config struct {
Server struct {
Host string // SERVER_HOST
Environment string // ENVIRONMENT, not SERVER_ENVIRONMENT
}
}
envcfg.Parse(&cfg,
envcfg.WithField("Server",
envcfg.WithField("Environment", envcfg.RootEnv("ENVIRONMENT")),
),
)Parse- Parse environment variables into a struct pointerMustParse- Same asParse, but panics on errorParseAs- Parse environment variables into a specific typeMustParseAs- Same asParseAs, but panics on error
Important
envcfg only parses exported fields.
| Option | Description | Default |
|---|---|---|
WithDelimiterTag |
Tag name for delimiter | delim |
WithSeparatorTag |
Tag name for separator | sep |
WithDecodeUnsetTag |
Tag name for decoding unset environment variables | decodeunset |
WithDefaultTag |
Tag name for default values | default |
WithExpandTag |
Tag name for expandable variables | expand |
WithFileTag |
Tag name for file variables | file |
WithNotEmptyTag |
Tag name for not empty variables | notempty |
WithRequiredTag |
Tag name for required variables | required |
WithIgnoreTag |
Tag name for ignored variables | ignore |
WithInitTag |
Tag name for initialization strategy | init |
| Option | Description | Default |
|---|---|---|
WithDelimiter |
Sets the default delimiter for array and map values | , |
WithSeparator |
Sets the default separator for map key-value pairs | : |
WithDecodeUnset |
Enables decoding unset environment variables by default | false |
WithInitVars |
Sets the initialization strategy to vars |
vars |
WithInitAny |
Sets the initialization strategy to any |
vars |
WithInitNever |
Sets the initialization strategy to never |
vars |
WithInitAlways |
Sets the initialization strategy to always |
vars |
WithExpand |
Enables environment variable expansion by default | false |
WithNotEmpty |
Enables validating that values are not empty by default | false |
WithRequired |
Enables marking fields as required by default | false |
| Option | Description | Default |
|---|---|---|
WithMatch |
Sets the matchers used to resolve field names, in order | Tag("env"), Field(), Snake() |
| Option | Description |
|---|---|
WithField |
Declares configuration for a single field, nestable |
WithFields |
Declares configuration for several fields, keyed by path |
| Option | Description |
|---|---|
WithTypeParser |
Registers a custom type parser |
WithTypeParsers |
Registers custom type parsers |
WithKindParser |
Registers a custom kind parser |
WithKindParsers |
Registers custom kind parsers |
| Option | Description |
|---|---|
WithDecoder |
Registers a custom decoder function for a specific interface |
| Option | Description |
|---|---|
WithLoader |
Registers a loader |
Loader options configure how environment variables are loaded and filtered before being matched to struct fields. These options allow you to:
- Add different sources of configuration (environment variables, files, external services)
- Filter which environment variables are considered
- Transform environment variable names before matching
- Set default values for when environment variables are not found
| Option | Description |
|---|---|
WithSource |
Adds a source to the loader |
WithSources |
Adds multiple sources to the loader |
WithOSEnvSource |
Adds OS environment variables as a source |
WithMapEnvSource |
Uses the provided map of environment variables as a source |
WithDotEnvSource |
Adds environment variables from a file as a source |
WithPrefix |
Combines WithTrimPrefix and WithHasPrefix |
WithSuffix |
Combines WithTrimSuffix and WithHasSuffix |
WithTransform |
Adds a transform function that modifies environment variable keys |
WithTrimPrefix |
Removes a prefix from environment variable names |
WithTrimSuffix |
Removes a suffix from environment variable names |
WithFilter |
Adds a custom filter to the loader |
WithHasPrefix |
Adds a prefix filter to the loader |
WithHasSuffix |
Adds a suffix filter to the loader |
WithHasMatch |
Adds a regular expression filter to the loader |
WithKeys |
Keeps only keys matching the given glob patterns (SERVERS_*, SERVERS_*_HOST, PORT_[0-9]); an invalid pattern is an error |
Filters inside a single loader are combined with OR, so adding more of them widens the set:
envcfg.WithLoader(
envcfg.WithKeys("APP_*"),
envcfg.WithKeys("*_PORT"),
)
// keeps APP_PORT, APP_HOST, OTHER_PORTTo narrow instead, nest loaders. The outer filters apply to what the inner loader produced, which gives you AND:
envcfg.WithLoader(
envcfg.WithLoader(
envcfg.WithKeys("APP_*"),
),
envcfg.WithKeys("*_PORT"),
)
// keeps APP_PORT onlyNote
WithHasMatch patterns are unanchored, so regexp.MustCompile("SERVERS_") also
matches XX_SERVERS_0_HOST. Anchor with ^ and $ when you want an exact shape:
regexp.MustCompile(^SERVERS_\d+_HOST$).
envcfg supports multiple configuration sources that can be used individually or combined. Built-in sources have dedicated With* functions, while custom sources and those maintained as separate Go modules (to keep dependencies isolated) can be added using WithSource.
| Option | Description |
|---|---|
WithOSEnvSource |
Adds OS environment variables as a source |
WithMapEnvSource |
Uses the provided map of environment variables as a source |
WithDotEnvSource |
Adds environment variables from a .env file as a source |
WithSource(awssm.New(...)) |
Adds AWS Secrets Manager as a source |
Sources are processed in the order they are added, with later sources taking precedence over earlier ones. This ordering allows you to:
- Set default values by adding a source with defaults first
- Override values by adding sources with higher precedence later
- Create a hierarchy of configuration (e.g., defaults → shared config → environment-specific config → local overrides)
envcfg.Parse(&cfg,
envcfg.WithLoader(
envcfg.WithMapEnvSource(map[string]string{
"APP_PORT": "8080",
"LOG_LEVEL": "info",
}),
envcfg.WithDotEnvSource(".env"),
envcfg.WithSource(awssm.New(
awssm.WithRegion("us-west-2"),
awssm.WithSecretID("myapp/config"),
)),
envcfg.WithOSEnvSource(),
),
)In this setup:
- Default values are loaded first
- Then values from the .env file are loaded next
- Then values from AWS Secrets Manager
- Finally, OS environment variables take precedence over both defaults and .env file