The problem it exists for
Most configuration libraries merge every source into one big map: files, environment variables, flags, defaults. That works right up until you need to know where a value came from, because the merge throws that away. And it really goes wrong when the library writes config back by saving that merged map. Comments vanish, the order goes, defaults the user never wrote get pinned into the file, and a password that came from an environment variable lands on disk. Viper’s old write path did exactly that, and it’s the reason go/config exists.
The editing half is measurable. Across eight real config files, editing one key at a time for 70 edits in all, yamldoc changed exactly one line every time and lost no comments. The usual YAML libraries got there 24 times out of 70.
How it works
One store, and it remembers
go/config reads every source into one store, in the order you add them, and records which source supplied each value as it goes, so you can ask it to explain where a setting came from. Reads come from a frozen snapshot, so a group of related reads never mixes values from before and after a reload, and a reload that fails is rejected while the last good config stays live.
Writes go back where they came from
A change is written to the file that already owns that key, and never to an environment variable or a flag. yamldoc does the editing: it changes only the bytes of the edit, then re-reads its own output and checks it before anything is saved.
A module per format and per backend
Only YAML is built in. Everything else is an adapter of its own, so you compile only what you use: seven formats (JSON, TOML and HCL read and write; XML, dotenv, INI and Java properties read only), four filesystems, three object stores (S3, Google Cloud Storage and Azure Blob), and eleven key-value and secrets backends, from Consul, etcd and Vault to the cloud secret managers and the macOS keychain. An adapter is handed a client you’ve already built and never holds credentials of its own.
Decisions and what they cost
- One owner for config. A single store owns all config input and output (spec 0002), instead of bolting a writer onto the old container, which would have needed locks and fingerprints only because three components shared the files. Replacing Viper had been estimated at 8,000 to 12,000 lines of work; once the store existed, the code left to replace was 781. What it cost: a breaking release, and real porting at a dozen call sites in go-tool-base. The writer I added and the Viper I deleted tells it.
- Its own filesystem interface. A six-method interface config defines for itself, over keeping afero (23% of the dependency graph on its own), the standard library’s read-only one, or one that can’t be swapped out in tests (spec 0003). What it cost: afero users wrap it themselves, or add the adapter.
- A module per format. One module for each format and each remote system, never a bundle (spec 0004, spec 0005), so nobody compiles every parser and cloud SDK to read one file. What it cost: twenty-seven modules to release, and every config core release becomes a release of all of them. A format per module, not a fatter config core is the argument.
- A YAML engine of its own. yamldoc moved off the parser it was built on to an engine it owns (yamldoc spec 0008). What it cost: an edit is four to seven times slower than the plain YAML libraries (about 4 ms against 0.6 ms on a 12 KB file), and by its own docs’ account, it’s young. YAML has comments for a reason is the launch.
Proof in use
- go-tool-base, colophon, keryx, krites and phpbotscout all read their config through it, and go-tool-base uses twenty of the adapters.
- 385 releases across the 28 modules since July 2026, with the core at v0.20. Twenty-seven adapters releasing against one core is easy to get out of step, so every adapter’s release merge request runs cicd’s go-core-currency check, which flags a release still built against an older core.
- yamldoc edits real config files one line at a time, measured above, in colophon and keryx as well as config itself.
Use it when, and when not to
Use it if your tool layers config from several places, needs to say where a value came from, writes changes back, or reloads while it runs.
Don’t, if you have one file, a few environment variables, no write-back and no reload: the README says plainly that Viper serves that well. It also has limits worth knowing: there are no transactions across sources, writes aren’t coordinated between processes, validation understands only required fields and enums, and a key with a dot in it can’t be reached. Explaining a value prints the value, secrets included, so mind where that output goes. yamldoc is strict, so a sloppy file fails, and its edits are slower on big files.
Where it’s going
Loading a Kubernetes ConfigMap or Secret mount as one layer per file is next, along with yamldoc’s release qualification and a few more editing operations for comments, tags and anchors.
Adapters, and the YAML editor beside it
- config-afero Bridge an afero filesystem a consumer already holds to config's own FS interface.
- config-aws-s3 Read and write a config file that lives in an AWS S3 bucket, staged and renamed over for an atomic commit.
- config-aws-secrets Read secrets from AWS Secrets Manager as a config layer.
- config-aws-ssm Read AWS SSM Parameter Store as a layer. Read-only: Parameter Store has no compare-and-swap write.
- config-azure-appconfig Read and write Azure App Configuration, each write guarded by the setting's ETag.
- config-azure-blob Read and write a config file in an Azure Blob container, through the same stage-and-rename machinery a local file uses.
- config-azure-keyvault Read secrets from Azure Key Vault as a config layer.
- config-billy Read and write config through a go-billy filesystem a tool already uses.
- config-consul Read and write HashiCorp Consul, with structure-preserving compare-and-swap writes.
- config-dotenv Read dotenv (.env) configuration, read-only with no added dependency.
- config-etcd A prefix of an etcd v3 cluster as a layer, with real compare-and-swap writes and a real change feed behind hot reload. Keys split on the separator into the tree, with the prefix stripped.
- config-filekv Treat a directory of single-value files as a config layer, where each filename is a key and its contents are the value. The shape Kubernetes secrets and Docker secrets already mount.
- config-gcp-gcs Read and write a config file in a Google Cloud Storage bucket, atomic per object.
- config-gcp-parameter Read GCP Parameter Manager as a layer, read-only.
- config-gcp-secret Read secrets from Google Cloud Secret Manager as a config layer.
- config-hcl Read and write HCL, treating it as a configuration format in its own right, not Terraform.
- config-ini Read INI configuration, read-only with no added dependency.
- config-iofs Read config from any io/fs.FS, including an embed.FS compiled into the binary. Read-only, as io/fs is.
- config-json Read and write JSON and JSON Lines, preserving the document's structure on write.
- config-keychain Read and write sensitive values in the OS keychain as a config layer, so a CLI's tokens never sit in a plain file on disk.
- config-properties Read Java .properties configuration, read-only with no added dependency.
- config-schema JSON Schema validation over a layered store: compose partial schemas per section, and get each failure attributed back to the layer that supplied the offending value.
- config-sftp Read and write config on a remote host over SFTP, staged and renamed over so a reader never sees a half-written file.
- config-toml Read and write TOML, structure-preserving.
- config-vault Read secrets from HashiCorp Vault as a config layer, with the client injected so you own how it authenticates.
- config-xml Read XML over the standard library alone, with no added dependency.
- yamldoc Edit a YAML document without destroying it: comments, key order, quoting and block styles survive a targeted change. Built for config, useful on its own.
The story in posts
Last reviewed .