Custom Decoders
Implementing custom type decoders
Custom decoders let you control exactly how envconfig deserializes an environment variable string into any Go type. By implementing a small interface on your type, you can handle formats that envconfig does not natively support — such as IP addresses, JSON-encoded structures, or domain-specific encodings. This page shows you how to implement the envconfig.Decoder interface, when to use it instead of built-in type support, and how to integrate custom decoders into your configuration structs.
Before you begin, make sure you have:
- Go 1.13 or later installed
- The envconfig library added to your module (see installation steps below)
- Familiarity with Go interfaces and pointer receivers
- A basic envconfig struct already set up — if not, complete the basic usage workflow first
- Add envconfig to your Go module:
go get github.com/kelseyhightower/envconfig
- Import it in any file where you define configuration structs or custom decoders:
import "github.com/kelseyhightower/envconfig"
No additional dependencies are required for custom decoders — the Decoder interface is defined within the envconfig package itself.
envconfig recognizes two interfaces that give a type control over its own deserialization:
| Interface | Method signature | When to use |
|---|---|---|
envconfig.Decoder | Decode(value string) error | Primary choice for custom type parsing |
flag.Value | Set(value string) error | Use when your type already satisfies the standard library flag.Value interface |
envconfig checks for Decoder first. If your type (or a pointer to your type) implements Decode(string) error, envconfig calls that method with the raw environment variable string and expects your implementation to populate the receiver. Return a non-nil error to signal that the value is invalid — envconfig will propagate this error from envconfig.Process.
Struct tag options work normally alongside custom decoders:
| Tag | Effect |
|---|---|
`envconfig:"VAR_NAME"` | Map the field to a specific environment variable name |
`default:"value"` | Supply a fallback string passed to Decode when the variable is unset |
`required:"true"` | Return an error from Process if the variable is absent |
Define Decode on a pointer receiver so the method can mutate the value in place.
Implementing envconfig.Decoder
Create a named type (or alias an existing type) and attach a Decode(string) error method with a pointer receiver:
import (
"net"
"github.com/kelseyhightower/envconfig"
)
type IPDecoder net.IP
func (ipd *IPDecoder) Decode(value string) error {
*ipd = IPDecoder(net.ParseIP(value))
return nil
}
Then use your type as a field in any envconfig struct. The struct tag envconfig:"DNS_SERVER" maps the field to that exact environment variable name:
type DNSConfig struct {
Address IPDecoder `envconfig:"DNS_SERVER"`
}
When you call envconfig.Process, envconfig reads the environment variable string and delegates parsing entirely to your Decode method:
var cfg DNSConfig
if err := envconfig.Process("", &cfg); err != nil {
log.Fatal(err)
}
Using flag.Value as an alternative
If your type already implements the flag.Value interface (i.e., it has a Set(string) error method), envconfig will call Set in the same way. No changes to your struct definition are needed — envconfig detects the interface automatically. Use Decoder when you are writing a type specifically for envconfig; use flag.Value when reusing a type shared with the flag package.
Example 1 — Decoding an IP address
Parse a plain IP address string into a net.IP-backed type.
export DNS_SERVER=8.8.8.8
package main
import (
"fmt"
"log"
"net"
"github.com/kelseyhightower/envconfig"
)
type IPDecoder net.IP
func (ipd *IPDecoder) Decode(value string) error {
*ipd = IPDecoder(net.ParseIP(value))
return nil
}
type DNSConfig struct {
Address IPDecoder `envconfig:"DNS_SERVER"`
}
func main() {
var cfg DNSConfig
if err := envconfig.Process("", &cfg); err != nil {
log.Fatal(err)
}
fmt.Println(net.IP(cfg.Address)) // 8.8.8.8
}
Expected output:
8.8.8.8
Example 2 — Decoding a complex structured type (map of JSON arrays)
This example shows how to handle a rich, domain-specific encoding that built-in support cannot manage — a semicolon-delimited list of key=JSON pairs decoded into map[string][]providerDetails.
export SMS_PROVIDER_WITH_WEIGHT='IND=[{"name":"SMSProvider1","weight":70},{"name":"SMSProvider2","weight":30}];US=[{"name":"SMSProvider1","weight":100}]'
package main
import (
"encoding/json"
"fmt"
"log"
"strings"
"github.com/kelseyhightower/envconfig"
)
type providerDetails struct {
Name string `json:"name"`
Weight int `json:"weight"`
}
type SMSProviderDecoder map[string][]providerDetails
func (sd *SMSProviderDecoder) Decode(value string) error {
result := map[string][]providerDetails{}
for _, pair := range strings.Split(value, ";") {
kv := strings.SplitN(pair, "=", 2)
if len(kv) != 2 {
return fmt.Errorf("invalid map item: %q", pair)
}
var providers []providerDetails
if err := json.Unmarshal([]byte(kv[1]), &providers); err != nil {
return fmt.Errorf("invalid map json: %w", err)
}
result[kv[0]] = providers
}
*sd = SMSProviderDecoder(result)
return nil
}
type SMSProviderConfig struct {
ProviderWithWeight SMSProviderDecoder `envconfig:"SMS_PROVIDER_WITH_WEIGHT"`
}
func main() {
var cfg SMSProviderConfig
if err := envconfig.Process("", &cfg); err != nil {
log.Fatal(err)
}
for region, providers := range cfg.ProviderWithWeight {
fmt.Printf("%s:\n", region)
for _, p := range providers {
fmt.Printf(" %s (weight %d)\n", p.Name, p.Weight)
}
}
}
Expected output:
IND:
SMSProvider1 (weight 70)
SMSProvider2 (weight 30)
US:
SMSProvider1 (weight 100)
Decode is never called
Symptom: The environment variable is set, but your Decode method does not appear to run — the field stays at its zero value.
Likely cause: The method is defined on a value receiver instead of a pointer receiver, so envconfig cannot detect the interface.
Fix: Change the receiver to a pointer:
// Wrong
func (ipd IPDecoder) Decode(value string) error { ... }
// Correct
func (ipd *IPDecoder) Decode(value string) error { ... }
envconfig.Process returns an error from inside Decode
Symptom: Process returns an error message that originates inside your Decode implementation.
Likely cause: The environment variable string is not in the format your decoder expects (wrong delimiter, malformed JSON, etc.).
Fix: Print or log the raw variable value before parsing to confirm what the decoder received:
fmt.Println(os.Getenv("YOUR_VAR"))
Then update either the environment variable value or your parsing logic to match.
Field is ignored even though the environment variable is set
Symptom: The struct field stays empty despite the variable being exported and correctly named.
Likely cause 1: The field has an ignored:"true" struct tag. envconfig skips it unconditionally.
Fix: Remove or set the tag to ignored:"false".
Likely cause 2: The envconfig:"..." tag value does not match the actual environment variable name (case-sensitive).
Fix: Verify that the tag value exactly matches the exported variable name, including underscores and case:
// Tag and variable must match exactly
type Config struct {
Address IPDecoder `envconfig:"DNS_SERVER"`
}
// export DNS_SERVER=8.8.8.8 ✓
// export dns_server=8.8.8.8 ✗
Default value is not passed to Decode
Symptom: You set a default:"..." tag, the environment variable is unset, but Decode is still not called with the default.
Likely cause: This is expected behavior only if your type does not implement Decoder. If it does implement Decoder, envconfig will pass the default string to Decode. Confirm that the method signature exactly matches Decode(string) error — a different signature means the interface is not satisfied and the field falls through to built-in handling.
Fix: Check the method signature and recompile. You can also add a quick compile-time assertion:
var _ envconfig.Decoder = (*IPDecoder)(nil)
This line will fail to compile if *IPDecoder does not implement envconfig.Decoder.