kvconf
Flat key = value config files for Go, with live reload. Standard library
only, no dependencies.
# ~/.config/myapp/config
theme = dark
lock.after = 5m
border = #ff8800 # a value may start with #
title = "keeps # inside"
bind.cmd+q = close-window
output.DP-1.scale = 1.5
- Typed decoding into a struct with
kvtags: strings, bools (on/off), numbers with bounds, durations, enums, lists, paths with~/, maps for dynamic keys (bind.*,output.<name>.<field>). - Live reload with each OS's own notifications: inotify (Linux), kqueue (macOS, BSD), ReadDirectoryChangesW (Windows), or polling.
- Safe for real configs: atomic saves, symlinked dotfiles, editors that save by rename, missing directories, broken files that keep the last good config, and warnings that never print values (they may be secrets).
- Fast: about 1 GB/s to parse, 2 allocations per reported change (see Performance).
go get github.com/bnema/kvconf
Quick start
type Config struct {
Theme string `kv:"theme" oneof:"dark|light"`
Lock time.Duration `kv:"lock.after,off" max:"1h"`
Shots kvconf.Path `kv:"screenshot.dir"`
Binds map[string]string `kv:"bind.*"`
}
func (c *Config) SetDefaults() { c.Theme, c.Lock = "dark", 5*time.Minute }
func main() {
path, _ := kvconf.DefaultPath("myapp", "config")
live, warnings, err := kvconf.Open(path, kvconf.LiveOptions[Config]{
OnChange: func(c kvconf.Change[Config]) { log.Println("reloaded", c.Keys) },
OnError: func(err error) { log.Println("config:", err) },
})
if err != nil {
log.Fatal(err)
}
for _, w := range warnings {
log.Println("config:", w) // "line 3: theme: must be dark or light"
}
go live.Run(ctx)
cfg := live.Get() // safe from any goroutine; never modified after
}
Without live reload: cfg, warnings, err := kvconf.Load[Config](path, kvconf.Options{}).
File format
- One
key = valueper line. Spaces around the key and value are trimmed. #starts a comment at the start of a line, or after a space or a tab.#ff8800anda#bare values. SeeOptions.Commentsfor other styles.- A value that is exactly
"..."(Go escapes) or'...'(literal) is unquoted.spawn "a b"stays as written. - UTF-8, with an optional BOM and
\r\nline ends. - A repeated key overrides the earlier one, with a warning.
Tags
| Tag | Meaning |
|---|---|
kv:"name" |
The key. A nested struct prefixes its keys with name. |
kv:",inline" |
A nested struct whose keys are not prefixed |
kv:"name,off" |
off sets the zero value |
kv:"name,nonempty" |
An empty value is rejected |
kv:"bind.*" |
A map: bind.<name> = v. A map of structs takes bind.<name>.<field>; names may contain dots |
kv:"*" |
[]kvconf.Entry or map[string]string collecting unknown keys |
min:"1" max:"10" |
Bounds of a number or duration (max:"1h") |
oneof:"a|b" |
Allowed values |
sep:"|" |
List separator (default ,) |
Any type implementing encoding.TextUnmarshaler works as a field.
Validate() error on the config checks rules spanning several keys.
SetDefaults() fills defaults, also for each new element of a map of structs.
Errors and warnings
By default a bad line or value becomes a Warning with its line and key,
and the rest of the file still applies. Options.Strict rejects the whole
file instead. Options.AllowUnknown, AllowDuplicates, RequireFile,
MaxBytes (1 MiB) and MaxLine (64 KiB) tune the rest.
Live keeps the current config when the file is broken or removed, and
skips saves that change no value.
Lower-level API
Parse,ParseBytes: file to[]Entrywithout decoding.Decode,Unmarshal,Marshal: entries or bytes to and from a struct.Watch(ctx, path, WatchOptions{...}, out): change notifications for one file, or every file of a directory withDir: true.Set,SetFile: change keys in place, keeping comments and layout.WriteFile: atomic replace, written through symlinks.ReadFile: bounded read that rejects FIFOs and devices without blocking.
Performance
Measured at v0.1.2 (1e7884d, 2026-10-10) with Go 1.27.1 on linux/amd64
with an AMD Ryzen 9 7900X3D. Values are medians of 10 runs. The input is a
realistic ~2.9 KB config of 100 lines with comments, 60 binds and 8 outputs.
| Benchmark | Time/op | Throughput | Bytes/op | Allocs/op |
|---|---|---|---|---|
Parse (file to entries) |
2.9 µs | 992 MB/s | 7.0 KiB | 2 |
Unmarshal (file to struct, maps included) |
8.3 µs | 351 MB/s | 13.4 KiB | 16 |
Decode (5 scalar entries) |
173 ns | 112 B | 1 | |
Marshal (struct to file) |
8.6 µs | 6.8 KiB | 10 | |
Set (edit one key in place) |
2.6 µs | 10.5 KiB | 5 | |
Watch (one write to one report, inotify) |
5.3 µs | 256 B | 2 |
Reproduce with:
go test -run '^$' -bench . -benchmem -count 10 .
License
MIT