# go-envconfig: environment variables into a struct, with the rules spelled out

> go-envconfig is a Go library that fills struct fields from environment variables or arbitrary lookup functions, driven by an `env` struct tag, with defaults, prefixes, required fields and a decoder chain. It suits a service that wants its configuration as a typed struct rather than a map of strings, and the tag options are where the real behaviour lives.

**sethvargo/go-envconfig** — A Go library for parsing struct tags from environment variables.

- Repository: https://github.com/sethvargo/go-envconfig
- Stars: 1,234 · Forks: 72
- Language: Go
- License: Apache-2.0
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/sethvargo-go-envconfig

## A tag, a struct, one call

Envconfig populates struct field values based on environment variables or arbitrary lookup functions. It supports pre-setting mutations, which is useful for things like converting values to uppercase, trimming whitespace, or looking up secrets.

The basic usage is a struct with `env` tags, some environment variables, and one call. The struct is:

```go
type MyConfig struct {
  Port     string `env:"PORT"`
  Username string `env:"USERNAME"`
}
```

and the processing is:

```go
var c MyConfig
if err := envconfig.Process(ctx, &c); err != nil {
  log.Fatal(err)
}
```

Two things are notable about the signature. It takes a `context.Context`, which is what makes the arbitrary lookup function variant possible, since a lookup can be a secret manager call that wants to be cancelled. And it returns an error rather than panicking on a malformed value, so a service can fail at startup with a clear message instead of starting with a silently wrong configuration.

The lookup function point is the one that matters most in practice, and the wording is deliberate. The library is not only an environment variable reader, it is a struct filler that happens to default to the environment. That distinction is what lets you write a test with a map instead of mutating the process environment, which in Go is global state and therefore awkward to touch in a parallel test suite.

The documentation also notes that you can use nested structs, and that any fields you want to process must be public. That is a reflection limitation rather than a design choice, and it is worth knowing before you design a struct with unexported configuration fields.

The repository layout is small and tells you the library is a focused one. There are `envconfig.go` for the main entry point, `decoding.go` for the decoder chain, `mutator.go` for the pre-set mutations, and the test files including `envconfig_doc_test.go` and `envconfig_mutator_doc_test.go`, which are executable documentation, the examples in the README being checked by the test suite. There is a `Makefile` whose only target runs the tests with `-count=1 -race -shuffle=on -timeout=10m`, which is a well configured Go test invocation.

## The default option has a rule about empty strings

The `default` tag option has a subtlety that is stated clearly and is the kind of thing that causes a bug if you miss it.

The default value is used when the environment variable is not set. The environment variable must not be set, for example `unset PORT`. If the environment variable is the empty string, envconfig considers that a value and the default will not be used.

So `PORT=` set to an empty string is not the same as `PORT` unset, and the difference determines whether your default applies. That is a defensible choice, since an explicitly empty value often means something different from an absent one, but it means the value of a variable in a shell environment, in a Dockerfile with `ENV PORT=`, and in a Kubernetes manifest with an empty value, can behave differently from the same variable absent.

The default can also refer to another field or another environment variable:

```go
type MyStruct struct {
  Port string `env:"PORT, default=5555"`
  User string `env:"USER, default=$CURRENT_USER"`
}
```

And there is an escaping rule for a literal dollar sign, which costs a double backslash in the struct tag:

```go
type MyStruct struct {
  Amount string `env:"AMOUNT, default=\\$5.00"` // Default: $5.00
}
```

And a literal backslash before a variable reference is escaped by doubling it, with the `$name` still expanded:

```go
type MyStruct struct {
  Filepath string `env:"FILEPATH, default=C:\\Personal\\\\$name"` // Default: C:\Personal\ followed by the value of $name
}
```

That last case is genuinely awkward, and it is the price of having a default value that can reference another value while also being able to contain a dollar sign and a backslash. The escaping is consistent, at least, and the examples in the documentation give the exact text to copy.

## Prefixes are how two structs share one shape

The `prefix` option is the one that solves a real problem, and the example shows why it exists.

Prefix sets the prefix to use for looking up environment variable keys on child structs and fields, and it is useful for shared configurations. The example reuses the same `RedisConfig` type in two places:

```go
type RedisConfig struct {
  Host string `env:"REDIS_HOST"`
  User string `env:"REDIS_USER"`
}

type ServerConfig struct {
  // CacheConfig will process values from $CACHE_REDIS_HOST and
  // $CACHE_REDIS_USER respectively.
  CacheConfig *RedisConfig `env:", prefix=CACHE_"`

  // RateLimitConfig will process values from $RATE_LIMIT_REDIS_HOST and
  // $RATE_LIMIT_REDIS_USER respectively.
  RateLimitConfig *RedisConfig `env:", prefix=RATE_LIMIT_"`
}
```

One struct type, two different sets of environment variables, and the environment variable names compose rather than being spelled out twice. The prefix applies to the child struct's own tags, so `REDIS_HOST` inside the child becomes `CACHE_REDIS_HOST` at the parent level.

That is a different design from the alternative, which is two separate struct types with different tags, or one type with a runtime parameter for the prefix. The tag approach keeps the type identical and puts the difference in the configuration, which means the two configurations are guaranteed to have the same shape, and a change to one cannot be forgotten in the other.

The `required` option is simpler and more fundamental. It marks a field as required, and if a field is required, decoding will error if the environment variable is unset:

```go
type MyStruct struct {
  Port string `env:"PORT, required"`
}
```

And `overwrite` force overwrites existing non-zero struct values if the environment variable was provided. That one has a documented interaction with `default` that is worth reading in full rather than skimming.

If the struct field has the zero value and a default is set, then if no environment variable is specified the struct field will be populated with the default value, and if an environment variable is specified the struct field will be populated with the environment variable value. If the struct field has a non-zero value and a default is set, then if no environment variable is specified the struct field's existing value will be used and the default is ignored, and if an environment variable is specified the struct field's existing value will be overwritten with the environment variable value.

So `overwrite` and a pre-populated struct is the combination that lets a caller set defaults in code and still have the environment win. Without `overwrite`, an environment variable does not override a value that is already set.

## Slices, maps and the two delimiter characters

Structured values are encoded in the environment as text, and the library exposes two separate characters for doing it, which is a detail worth understanding because the defaults are not the same.

Slices are specified as comma-separated values:

```bash
export MYVAR="a,b,c,d" # []string{"a", "b", "c", "d"}
```

And maps are specified as comma-separated key:value pairs:

```bash
export MYVAR="a:b,c:d" # map[string]string{"a":"b", "c":"d"}
```

The `delimiter` option chooses a custom character to denote individual slice and map entries, and the default value is the comma. So if your values contain commas, you change the delimiter:

```go
type MyStruct struct {
  MyVar []string `env:"MYVAR, delimiter=;"`
}
```

```bash
export MYVAR="a;b;c;d" # []string{"a", "b", "c", "d"}
```

The `separator` option chooses a custom character to denote the separation between keys and values in map entries, and the default value is the colon:

```go
type MyStruct struct {
  MyVar map[string]string `env:"MYVAR, separator=|"
}
```

```bash
export MYVAR="a|b,c|d" # map[string]string{"a":"b", "c":"d"}
```

Splitting delimiter from separator is the right call, because the two jobs are different. A list of items and a key inside an item are different levels of structure, and a single character cannot do both. The example makes the case: with a pipe as the separator and a comma as the delimiter, `a|b,c|d` is two map entries whose values contain commas, and with the defaults the same string would be four malformed pairs.

There is a related special case for slices. Byte slices are special cased and interpreted as strings from the environment, which is worth knowing if you have a `[]byte` field and expected base64 or a list of numbers.

Two more options control the edges of the whole thing. `noinit` does not initialize struct fields unless environment variables were provided, and the default behaviour is to deeply initialize all fields to their default, which is the zero, value. And `decodeunset` forces envconfig to run decoders even on unset environment variable values, since the default behaviour is to skip running decoders on unset values.

## The decoder chain, and where the complexity is

How a value becomes a Go value is the part of the library that does the real work, and the documentation lists the chain by the interface a type implements.

Durations are specified as a parsable Go duration, so `export MYVAR="10m"` gives you ten minutes in a `time.Duration` field.

Types that implement `TextUnmarshaler` or `BinaryUnmarshaler` are processed as such. Types that implement `json.Unmarshaler` are processed as such. Types that implement `gob.Decoder` are processed as such.

So the rule is that a type can bring its own parsing, and the library will notice. That is the mechanism that makes `*url.URL` work, and it is why the `noinit` and `decodeunset` options exist at all. If a field is not set, there is nothing to hand to the decoder, and by default the decoder is skipped, which means a `*url.URL` field stays nil rather than becoming a pointer to an empty URL. `noinit` changes that by not initializing the field in the first place, and `decodeunset` changes it by running the decoder anyway on the unset value.

There is one overarching rule that governs the whole chain. Complex types are only decoded or unmarshalled when the environment variable is defined or a default value is specified.

That is the behaviour that stops a `*url.URL` from becoming a non-nil pointer to a nonsense URL when the variable is absent, and it is the kind of rule that is much easier to accept when it is stated up front.

The struct walking rules are documented alongside. Envconfig walks the entire struct, including nested structs, so deeply nested fields are also supported. If a nested struct is a pointer type, it will automatically be instantiated to the non-nil value, and there is a separate section on changing that behaviour.

For a library of this size, the interesting file list is short. `decoding.go` holds the decoder chain, `mutator.go` holds the pre-set mutations that the introduction mentions, uppercase and trimming and secret lookups, and `envconfig.go` holds the main logic. The only non-test dependency is `github.com/google/go-cmp` at v0.7.0, which is a test helper, so the library itself has no runtime dependencies at all.

The licence is Apache-2.0, the module is `github.com/sethvargo/go-envconfig/v2` requiring Go 1.25, and the v2.0.0 release landed on 2026-09-23 after a long run of 1.4.x patches.

## Conclusion

go-envconfig fits a Go service that wants configuration as a struct it can pass around, because the field types drive the parsing rather than every call site doing its own conversion, and because the same struct can be filled from a lookup function instead of the process environment, which is what makes it testable. Skip it if you need per-field validation rules, since the tag options cover presence, defaults and prefixes but not ranges or formats, and note the version jump, since v2.0.0 landed in September 2026 with the module path now `github.com/sethvargo/go-envconfig/v2` and Go 1.25 required. Before you upgrade, read the migration notes for the major version and check your struct tags, because the tag syntax is the API. The last push was on 2026-10-10.

## FAQ

### What does go-envconfig do?

It populates struct field values based on environment variables or arbitrary lookup functions, using an `env` struct tag, and it supports pre-set mutations for things like converting values to uppercase, trimming whitespace, or looking up secrets.

### When does the default value in go-envconfig apply?

Only when the environment variable is not set. If the variable is the empty string, envconfig considers that a value and the default will not be used.

### How do I use a prefix for nested structs in go-envconfig?

Add the prefix option to the child's tag, for example `env:", prefix=CACHE_"`. The prefix is applied to the child struct's own keys, so a child tagged `env:"REDIS_HOST"` becomes `$CACHE_REDIS_HOST` at the parent level.

### How are slices and maps encoded in go-envconfig?

Slices are comma separated values and maps are comma separated key:value pairs. The delimiter option changes the character between entries, defaulting to a comma, and the separator option changes the character between a key and a value, defaulting to a colon.

### Which Go version does go-envconfig v2 need?

The go.mod declares go 1.25, and the module path is now github.com/sethvargo/go-envconfig/v2 after the v2.0.0 release on 2026-09-23. The library itself has no runtime dependencies, only google/go-cmp for tests.

## Sources

- [Issues](https://github.com/sethvargo/go-envconfig/issues)
- [License: Apache-2.0](https://github.com/sethvargo/go-envconfig/blob/main/LICENSE)
- [README](https://github.com/sethvargo/go-envconfig/blob/main/README.md)
- [Releases](https://github.com/sethvargo/go-envconfig/releases)
- [sethvargo/go-envconfig on GitHub](https://github.com/sethvargo/go-envconfig)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sethvargo-go-envconfig
