A koanf provider that reads koanf-default:"…" struct tags and emits a nested map[string]any of parsed defaults. Load it as the lowest-priority layer and let file, env, and flag providers override naturally.
Zero production dependencies. Distributed as a standalone Go module.
The conventional way to declare defaults in a koanf-based app is a hand-built confmap.Provider:
k.Load(confmap.Provider(map[string]any{
"server.port": 8080,
"server.host": "localhost",
"server.timeout": "30s",
}, "."), nil)That works, but the defaults drift away from the struct, lose type-safety (any everywhere), and duplicate every field name. This provider lets defaults live next to the field they describe:
type Config struct {
Server struct {
Host string `koanf:"host" koanf-default:"localhost"`
Port int `koanf:"port" koanf-default:"8080"`
Timeout time.Duration `koanf:"timeout" koanf-default:"30s"`
} `koanf:"server"`
LogLevel string `koanf:"log_level" koanf-default:"info"`
}go get github.com/uded/koanf-structdefaultsRequires Go 1.23+.
package main
import (
"log"
"time"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/uded/koanf-structdefaults"
)
type Config struct {
Server struct {
Host string `koanf:"host" koanf-default:"localhost"`
Port int `koanf:"port" koanf-default:"${PORT:-8080}"`
Timeout time.Duration `koanf:"timeout" koanf-default:"30s"`
} `koanf:"server"`
LogLevel string `koanf:"log_level" koanf-default:"info"`
}
func main() {
k := koanf.New(".")
// Layer 1 (lowest priority): declared defaults.
p, err := structdefaults.New(&Config{}, structdefaults.Options{Delim: "."})
if err != nil {
log.Fatal(err)
}
if err := k.Load(p, nil); err != nil {
log.Fatal(err)
}
// Layer 2: file overrides defaults.
_ = k.Load(file.Provider("config.yaml"), yaml.Parser())
// Layer 3 (highest): env overrides everything.
_ = k.Load(env.Provider("APP_", ".", nil), nil)
var cfg Config
if err := k.Unmarshal("", &cfg); err != nil {
log.Fatal(err)
}
}type Options struct {
Delim string // required ("." for typical configs)
PathTag string // default: "koanf"
DefaultTag string // default: "koanf-default"
Lookup EnvLookup // default: os.LookupEnv
Strict bool // default: false
}| Field | Effect |
|---|---|
Delim |
Path separator. Empty value → ErrInvalidConfig from New. |
PathTag / DefaultTag |
Override the tag names used to name fields and read defaults. Empty → library default. |
Lookup |
Custom env-var resolver. Pass nil to use os.LookupEnv. Useful for hermetic tests, secret stores, or precedence layering. |
Strict |
When true, New walks the entire struct eagerly and surfaces any error at construction time rather than at the first Read. |
| Type | Notes |
|---|---|
string, bool |
parsed via strconv |
int, int8/16/32/64 |
parsed via strconv.ParseInt with the matching bit size |
uint, uint8/16/32/64 |
parsed via strconv.ParseUint |
float32, float64 |
parsed via strconv.ParseFloat |
time.Duration |
parsed via time.ParseDuration (e.g. "30s", "1h15m") |
Any encoding.TextUnmarshaler |
both value-receiver and pointer-receiver |
Nested struct |
walked recursively |
| Pointer-to-struct | walked via a temporary instance — your input struct is never mutated |
| Anonymous embedded struct | flattened (squash) unless it carries an explicit koanf:"name" tag |
| Tag | Behavior |
|---|---|
koanf:"name" |
path segment is name |
koanf:"-" |
field is skipped entirely (overrides any koanf-default) |
koanf:"" or absent |
path segment is the Go field name |
koanf-default:"value" |
declared default for this field |
koanf-default:"" |
empty-string default (only meaningful for string) |
koanf-default: absent |
no entry emitted (output is sparse) |
Tag values may include POSIX-style ${VAR} and ${VAR:-fallback} references. Substitution runs before type parsing, so it works for any field type:
type Config struct {
Host string `koanf:"host" koanf-default:"${HOST:-localhost}"`
Port int `koanf:"port" koanf-default:"${PORT:-8080}"`
Timeout time.Duration `koanf:"timeout" koanf-default:"${TIMEOUT:-30s}"`
Region string `koanf:"region" koanf-default:"${REGION}"` // no fallback
}| Form | Behavior |
|---|---|
${VAR} |
Resolves VAR. If unset, returns ErrUnsetEnv. |
${VAR:-fallback} |
Uses VAR if set; otherwise the literal fallback (which may be empty: ${VAR:-}). |
$$ … literal $ |
Not yet supported — request via issue if needed. |
Substitution is single-pass and non-recursive: env-var values are not re-scanned for ${...}. This is intentional — prevents indirect env-var resolution.
For hermetic tests or secret stores (Vault, AWS Secrets Manager), pass a custom Lookup:
p, err := structdefaults.New(&cfg, structdefaults.Options{
Delim: ".",
Lookup: func(name string) (string, bool) {
return vaultClient.Get(name)
},
})Struct-tag values are compiled into your binary and visible in strings ./binary. Never embed secrets directly in koanf-default:"…" — use ${VAR} substitution and resolve them from your environment or secret store at runtime. Errors from this library may include env-var names (not values); route library errors through your standard log-redaction pipeline.
By default, parse errors and unset env vars surface from Read() at koanf load time. Set Strict: true to force eager validation at construction:
p, err := structdefaults.New(&cfg, structdefaults.Options{
Delim: ".",
Strict: true,
})
// err is non-nil if any default fails to parse, any env var is unset
// without a fallback, or the struct contains a cyclic type.Strict is a startup-time correctness gate: catches typos in tag values before the app starts serving traffic.
All errors are sentinel-wrapped via %w; match with errors.Is or errors.As:
| Sentinel | When |
|---|---|
ErrInvalidConfig |
Options.Delim is empty (returned from New). |
ErrInvalidInput |
target is nil, a non-struct, or a nil pointer-to-struct (returned from New). |
ErrCyclicType |
walker encountered a struct type that recursively references itself. |
ErrInvalidValue |
a tag value cannot be parsed for an otherwise-supported type (e.g. koanf-default:"8O8O" on int). Use errors.As to recover the underlying *strconv.NumError etc. |
ErrUnsupportedType |
the field's Go type cannot carry a default at all (slice, map, channel, func). Programmer error: fix the struct. |
ErrUnsetEnv |
${VAR} reference with no fallback and the env var is unset. |
ErrUnsupported |
returned from ReadBytes() (this provider is Read()-only). |
Read() returns a nested map[string]any whose tree shape mirrors the koanf path layout:
map[string]any{
"server": map[string]any{
"host": "localhost",
"port": 8080,
"timeout": time.Duration(30 * time.Second),
},
"log_level": "info",
}This matches what every other koanf provider emits and merges correctly with overrides from file, env, and flag layers.
- Slice / map defaults. Comma-split syntax has too many escaping pitfalls; for the rare slice/map default, build it via
confmapor initialize the field directly. A futurekoanf-default-json:"[1,2,3]"tag is on the roadmap. - Validation (
required:"…"). Different concern; out of scope. - Mutating your input struct. Read-only by design.
MIT — see LICENSE.