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
kv tags: 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 750 MB/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{}).
- One
key = value per 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.
#ff8800 and a#b are values. See Options.Comments for 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\n line ends.
- A repeated key overrides the earlier one, with a warning.
| 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 []Entry without 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 with Dir: 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.
Measured at v0.1.1 (ebc8127, 2026-10-10) with Go 1.27.1 on linux/amd64
with an AMD Ryzen 9 7900X3D. Values are medians of 6 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) |
3.9 µs |
744 MB/s |
10.4 KiB |
5 |
Unmarshal (file to struct, maps included) |
12.2 µs |
238 MB/s |
21.7 KiB |
47 |
Decode (5 scalar entries) |
171 ns |
|
112 B |
1 |
Marshal (struct to file) |
11.6 µs |
|
15.5 KiB |
19 |
Set (edit one key in place) |
2.5 µ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 6 .
License
MIT
source
1# kvconf
2
3Flat `key = value` config files for Go, with live reload. Standard library
4only, no dependencies.
5
6```ini
7# ~/.config/myapp/config
8theme = dark
9lock.after = 5m
10border = #ff8800 # a value may start with #
11title = "keeps # inside"
12bind.cmd+q = close-window
13output.DP-1.scale = 1.5
14```
15
16- **Typed decoding** into a struct with `kv` tags: strings, bools (`on/off`),
17 numbers with bounds, durations, enums, lists, paths with `~/`, maps for
18 dynamic keys (`bind.*`, `output.<name>.<field>`).
19- **Live reload** with each OS's own notifications: inotify (Linux), kqueue
20 (macOS, BSD), ReadDirectoryChangesW (Windows), or polling.
21- **Safe for real configs**: atomic saves, symlinked dotfiles, editors that
22 save by rename, missing directories, broken files that keep the last good
23 config, and warnings that never print values (they may be secrets).
24- **Fast**: about 750 MB/s to parse, 2 allocations per reported change
25 (see [Performance](#performance)).
26
27```sh
28go get github.com/bnema/kvconf
29```
30
31## Quick start
32
33```go
34type Config struct {
35 Theme string `kv:"theme" oneof:"dark|light"`
36 Lock time.Duration `kv:"lock.after,off" max:"1h"`
37 Shots kvconf.Path `kv:"screenshot.dir"`
38 Binds map[string]string `kv:"bind.*"`
39}
40
41func (c *Config) SetDefaults() { c.Theme, c.Lock = "dark", 5*time.Minute }
42
43func main() {
44 path, _ := kvconf.DefaultPath("myapp", "config")
45 live, warnings, err := kvconf.Open(path, kvconf.LiveOptions[Config]{
46 OnChange: func(c kvconf.Change[Config]) { log.Println("reloaded", c.Keys) },
47 OnError: func(err error) { log.Println("config:", err) },
48 })
49 if err != nil {
50 log.Fatal(err)
51 }
52 for _, w := range warnings {
53 log.Println("config:", w) // "line 3: theme: must be dark or light"
54 }
55 go live.Run(ctx)
56
57 cfg := live.Get() // safe from any goroutine; never modified after
58}
59```
60
61Without live reload: `cfg, warnings, err := kvconf.Load[Config](path, kvconf.Options{})`.
62
63## File format
64
65- One `key = value` per line. Spaces around the key and value are trimmed.
66- `#` starts a comment at the start of a line, or after a space or a tab.
67 `#ff8800` and `a#b` are values. See `Options.Comments` for other styles.
68- A value that is exactly `"..."` (Go escapes) or `'...'` (literal) is
69 unquoted. `spawn "a b"` stays as written.
70- UTF-8, with an optional BOM and `\r\n` line ends.
71- A repeated key overrides the earlier one, with a warning.
72
73## Tags
74
75| Tag | Meaning |
76| --- | --- |
77| `kv:"name"` | The key. A nested struct prefixes its keys with `name.` |
78| `kv:",inline"` | A nested struct whose keys are not prefixed |
79| `kv:"name,off"` | `off` sets the zero value |
80| `kv:"name,nonempty"` | An empty value is rejected |
81| `kv:"bind.*"` | A map: `bind.<name> = v`. A map of structs takes `bind.<name>.<field>`; names may contain dots |
82| `kv:"*"` | `[]kvconf.Entry` or `map[string]string` collecting unknown keys |
83| `min:"1" max:"10"` | Bounds of a number or duration (`max:"1h"`) |
84| `oneof:"a\|b"` | Allowed values |
85| `sep:"\|"` | List separator (default `,`) |
86
87Any type implementing `encoding.TextUnmarshaler` works as a field.
88`Validate() error` on the config checks rules spanning several keys.
89`SetDefaults()` fills defaults, also for each new element of a map of structs.
90
91## Errors and warnings
92
93By default a bad line or value becomes a `Warning` with its line and key,
94and the rest of the file still applies. `Options.Strict` rejects the whole
95file instead. `Options.AllowUnknown`, `AllowDuplicates`, `RequireFile`,
96`MaxBytes` (1 MiB) and `MaxLine` (64 KiB) tune the rest.
97
98`Live` keeps the current config when the file is broken or removed, and
99skips saves that change no value.
100
101## Lower-level API
102
103- `Parse`, `ParseBytes`: file to `[]Entry` without decoding.
104- `Decode`, `Unmarshal`, `Marshal`: entries or bytes to and from a struct.
105- `Watch(ctx, path, WatchOptions{...}, out)`: change notifications for one
106 file, or every file of a directory with `Dir: true`.
107- `Set`, `SetFile`: change keys in place, keeping comments and layout.
108- `WriteFile`: atomic replace, written through symlinks.
109- `ReadFile`: bounded read that rejects FIFOs and devices without blocking.
110
111## Performance
112
113Measured at `v0.1.1` (`ebc8127`, 2026-10-10) with Go 1.27.1 on linux/amd64
114with an AMD Ryzen 9 7900X3D. Values are medians of 6 runs. The input is a
115realistic ~2.9 KB config of 100 lines with comments, 60 binds and 8 outputs.
116
117| Benchmark | Time/op | Throughput | Bytes/op | Allocs/op |
118|---|---:|---:|---:|---:|
119| `Parse` (file to entries) | 3.9 µs | 744 MB/s | 10.4 KiB | 5 |
120| `Unmarshal` (file to struct, maps included) | 12.2 µs | 238 MB/s | 21.7 KiB | 47 |
121| `Decode` (5 scalar entries) | 171 ns | | 112 B | 1 |
122| `Marshal` (struct to file) | 11.6 µs | | 15.5 KiB | 19 |
123| `Set` (edit one key in place) | 2.5 µs | | 10.5 KiB | 5 |
124| `Watch` (one write to one report, inotify) | 5.3 µs | | 256 B | 2 |
125
126Reproduce with:
127
128```sh
129go test -run '^$' -bench . -benchmem -count 6 .
130```
131
132## License
133
134MIT