August 10, 2026·10 min read·goconfigurationkoanfviper

koanf and Viper for Go configuration

koanf offers explicit composition, case-sensitive keys, and fine-grained dependency isolation. Viper provides a more integrated configuration system with established conventions and broader built-in tooling.

Introduction

I recently uncovered koanf, when Omer, my partner in crime at Yaklab, commented on a PR at work where I was seemingly hand-rolling what viper does out of the box. In actual fact I was returning the providence of the keys which because of the limitations of the viper apis, was only possible using the primitives from scratch. And it got me thinking, do other people have this and what could do this more elegantly.

Scope of the comparison

Configuration libraries sit between several representations of the same data. A typical Go service may receive defaults from code, structured values from a file, secrets from a remote store, and deployment-specific overrides from environment variables or flags. The library has to define how those sources are decoded, merged, queried, and sometimes reloaded while the process is running.

This comparison uses koanf commit 6ad56fe from 9 August 2026 and Viper commit 528f741 from 15 October 2025. The Viper revision is an untagged commit on master, 36 commits after v1.21.0, rather than that release itself. The comparison therefore describes that pinned development revision. The koanf revision follows its latest root-module tag, v2.3.6.

Neither library is a general schema or validation system. Both primarily build a configuration map and decode it into application types. Validation still belongs in the application or in a separate validation package.

Two different designs

koanf has a small core organized around two interfaces. Provider supplies either bytes or a nested map, while Parser converts between bytes and a nested map. Each interface has two methods in koanf/interfaces.go:5-20.

type Provider interface {
	ReadBytes() ([]byte, error)
	Read() (map[string]any, error)
}

type Parser interface {
	Unmarshal([]byte) (map[string]any, error)
	Marshal(map[string]any) ([]byte, error)
}

(*Koanf).Load is the main composition operation. When a parser is present, it calls ReadBytes and then Unmarshal. A nil parser selects the provider's already-structured Read result. The complete dispatch occupies koanf/koanf.go:90-124, which makes custom sources and formats straightforward to implement.

Viper presents a larger integrated API. A Viper instance contains separate registries for explicit overrides, flags, environment bindings, file configuration, remote values, defaults, and aliases. File discovery, codecs, file watching, writing, typed conversion, and flag integration are available from the main package. Its Viper type and constructor initialize these registries in viper/viper.go:71-183.

Viper also has package-level functions backed by a global instance initialized in viper/viper.go:48-52. viper.GetString and similar calls delegate to that object. viper.New() remains available and is the better fit for libraries, tests, and processes with more than one independent configuration. koanf exposes configuration operations only through instances created with koanf.New or koanf.NewWithConf.

Dependency boundaries

The koanf repository contains 33 Go modules at this revision. They comprise the root, the maps utility, nine parsers, twenty providers, a behavioral test module, and an examples module. The root go.mod has three direct requirements: go-viper/mapstructure/v2, koanf/maps, and mitchellh/copystructure. reflectwalk is its sole indirect requirement, as recorded in koanf/go.mod:5-11.

Each parser and provider has its own module. The AWS, Azure, Vault, Consul, etcd, and NATS dependencies therefore remain outside an application unless it selects the corresponding provider. The file provider does not import koanf itself, although its own module requires fsnotify for watching. Most plugins satisfy the core interfaces structurally. Only five plugin modules in this checkout require koanf/v2: cliflagv2, cliflagv3, k8smount, nats, and parameterstore.

That separation is useful in services with narrow configuration requirements. A custom provider can also implement the two methods without depending on koanf, while flag providers use small local interfaces such as KoanfIntf in providers/basicflag/basicflag.go:17-21.

Viper's root go.mod declares ten direct dependencies, one of which is the test library testify. Runtime dependencies cover fsnotify, mapstructure, TOML and YAML codecs, dotenv parsing, file-system abstraction, discovery, value conversion, and pflag integration. These dependencies support features that are immediately available from the main package, but an application pays for a broader module and build graph even if it only reads JSON from a byte slice.

Viper does isolate its remote integrations in github.com/spf13/viper/remote. That second module depends on sagikazarmark/crypt and brings in the clients for etcd, Consul, Firestore, and NATS. The supported provider names are defined in viper/remote.go:11-15. This arrangement keeps the largest remote dependency graph optional, although koanf goes further by separating each remote system into its own module.

Keys and instances

koanf preserves key case. server.Port and server.port are separate paths because neither the core load path nor the map utilities apply case folding. This behavior is useful when the input format or an external system has case-significant keys. Environment normalization is explicit in the environment provider's TransformFunc, documented in providers/env/env.go:21-38.

A koanf delimiter belongs to an instance. koanf.New("/") makes server/http/port a path. With an empty delimiter, Unflatten leaves already-flat keys literal, while Flatten concatenates nested key parts with no separator. These behaviors are implemented in koanf/maps/maps.go:49-58 and koanf/maps/maps.go:90-94.

Viper deliberately makes configuration keys case-insensitive. Get lowercases its argument before searching, and values loaded into its registries are normalized for case-insensitive access. The behavior is documented and implemented in viper/viper.go:699-717. Configurations containing distinct Port and port keys cannot retain that distinction through Viper, and write-back cannot reproduce the original casing reliably.

Viper uses "." as its default delimiter, but it is not fixed in this version. viper.NewWithOptions(viper.KeyDelimiter("::")) constructs an instance with another delimiter, according to viper/viper.go:200-205. koanf makes the same choice through the required argument to New, while Viper presents it as an option.

Precedence and merging

Viper assigns each source category a fixed priority. An explicit Set wins over a changed flag, followed by environment variables, file configuration, remote key/value data, and defaults. Get states this order in viper/viper.go:699-712, and find implements it in viper/viper.go:1212-1319. Calling SetDefault after reading a file does not give the default higher priority because the two values remain in separate registries.

The fixed hierarchy is useful when a project follows the same convention. Developers can recognize the precedence without reconstructing the order of initialization calls. It is less convenient when a deployment needs an unusual policy, such as a generated local file overriding an environment variable.

koanf merges every source into one map. The incoming map recursively replaces matching scalar or slice values, while nested maps are combined. Later Load calls take precedence because Load passes the incoming map as the source and the existing configuration as the destination to maps.Merge in koanf/koanf.go:455-461. maps.Merge assigns incoming values by reference and explicitly performs no copy, so the merged configuration can retain references to provider-supplied values, as documented and implemented in koanf/maps/maps.go:114-145.

k := koanf.New(".")

if err := k.Load(confmap.Provider(defaults, "."), nil); err != nil {
	return err
}
if err := k.Load(file.Provider("config.yaml"), yaml.Parser()); err != nil {
	return err
}
if err := k.Load(env.Provider(".", envOptions), nil); err != nil {
	return err
}

In this sequence the transformed environment map wins over the file, and the file wins over the defaults. Reordering the calls changes the policy. This is more flexible than Viper's registry hierarchy, but the policy is implicit unless the application keeps source loading together and tests conflicting values.

Conf.StrictMerge rejects a later value whose Go type differs from the existing value. The check is implemented by maps.MergeStrict in koanf/maps/maps.go:147-195. This can detect accidental changes from a map to a scalar, although format differences can also trigger it. The koanf README notes that JSON numbers and YAML integers may reach the merge as different Go types. WithMergeFunc supplies a custom merge operation for a particular Load, as defined in koanf/options.go:25-32.

Decoding and typed access

Both revisions use github.com/go-viper/mapstructure/v2 at version v2.4.0. koanf's default decoder enables weakly typed input, parses duration strings, invokes encoding.TextUnmarshaler, and uses the koanf struct tag. UnmarshalConf.DecoderConfig permits the caller to replace the full decoder configuration. These defaults are assembled in koanf/koanf.go:265-298.

Viper also enables weakly typed input. Its default hooks handle durations and comma-separated slices, while decoder options can replace the hook or alter other mapstructure settings. The configuration is built in viper/viper.go:1000-1024. Viper uses mapstructure's normal tag conventions unless the caller supplies decoder options.

koanf provides typed getters, Cut for a subtree, Slices for arrays of configuration objects, Delete, Copy, and serialization through any Parser. Its Must getters require care because several reject valid zero values. MustInt panics when the value is zero, and MustString panics for an empty string, as implemented in koanf/getters.go:126-139 and koanf/getters.go:372-391. Applications that permit those values should use Exists with the ordinary getter or decode into a struct and validate it.

koanf also makes defensive copies when returning configuration data. Raw and All copy their maps through maps.Copy, while Get copies maps and other reference-bearing values. These paths use copystructure and preserve Go types, as implemented in koanf/maps/maps.go:254-260 and koanf/koanf.go:330-371. The float64 values produced by the JSON test originate in encoding/json during parsing, while TestRaw_YamlTypes confirms that YAML integers remain int values after Raw in tests/koanf_test.go:987-1028. Large configurations read frequently through Raw or map-valued Get will allocate accordingly.

Environment decoding exposes a notable Viper edge case. AutomaticEnv checks the process environment during Get, but the default Unmarshal begins with AllKeys. AllKeys can enumerate explicitly bound environment keys, but it cannot enumerate keys known only through AutomaticEnv, as shown by viper/viper.go:963-977 and viper/viper.go:1970-1986. Defaults, explicit bindings, or another loaded source make those keys visible. The pinned version also provides ExperimentalBindStruct, which derives candidate keys from the destination struct and has a specific test for environment-only fields in viper/viper_test.go:967-1027.

koanf's environment provider enumerates os.Environ during Read, so accepted variables become ordinary map entries before unmarshalling. The caller must provide any prefix removal, lowercasing, delimiter substitution, or value conversion through TransformFunc. Viper supplies more predefined environment operations through BindEnv, AutomaticEnv, prefixes, and SetEnvKeyReplacer.

Watching and reloading

Viper includes file watching in its main package. WatchConfig creates an fsnotify watcher, calls ReadInConfig after a relevant write or replacement event, and then invokes the callback registered with OnConfigChange. The reload occurs inside viper/viper.go:282-328. A removed key disappears from the file registry because ReadInConfig replaces that map rather than merging into it.

koanf does not include watching in Provider. Individual providers may expose a Watch method in addition to the required interface. This checkout has such methods in the file, Consul, etcd, NATS, AppConfig, Kubernetes mount, and POSIX-compliant pflag providers. Their callback shapes are similar but are not enforced by the core interface.

A koanf watcher reports an event and leaves reloading to the application. Reloading into the existing instance will retain keys removed from the source because the normal operation is a merge. The README example accordingly creates a fresh instance before loading the changed file. A concurrent service can build the replacement completely and then publish it through its own synchronization mechanism.

var current atomic.Pointer[koanf.Koanf]

reload := func() error {
	next := koanf.New(".")
	if err := next.Load(source, yaml.Parser()); err != nil {
		return err
	}
	current.Store(next)
	return nil
}

if err := source.Watch(func(_ any, err error) {
	if err == nil {
		_ = reload()
	}
}); err != nil {
	return err
}

The koanf model gives the application control over validation and publication of a new snapshot. Viper requires less application code for ordinary file reloads and also provides separate remote watching operations through its optional remote module.

Integrated facilities in Viper

Viper contains several facilities for which koanf expects explicit application composition. SetConfigName, AddConfigPath, and ReadInConfig search configured directories for a supported file. The pinned version also defines a Finder interface and WithFinder option in viper/finder.go:9-27, alongside an experimental built-in finder path.

WriteConfig, WriteConfigAs, and their Safe variants serialize configuration to disk. Viper also provides aliases through RegisterAlias, defaults through SetDefault, and direct pflag integration through BindPFlag and BindPFlags. Its pflag support is designed to work with Cobra and is documented as such in viper/README.md:276-283.

koanf can represent defaults with an initial confmap load, flags with a provider, and output through Marshal. It does not provide Viper's file discovery or disk-writing workflow. Applications that need an editable command-line configuration file may therefore need appreciably more code around koanf.

Viper has no public unset operation in this checkout. IsSet returns whether find resolves a non-nil value, and placing nil in the override registry does not mask a lower-priority source because the search continues after nil. koanf supplies Delete, although a later reload from another source can add the key again.

Repository and release tradeoffs

koanf's module boundaries reduce application dependencies, but they increase release and upgrade work. An application may need separate requirements for the core, a file provider, an environment provider, and one or more parsers. Those modules have independent versions and tags, so a coordinated change can require several version updates.

The checkout contains 194 Git tags. Root tags reach v2.3.6, while provider and parser modules range across v0, v1, and v2 import paths. The repository has no release workflow, Makefile, or linter configuration at this revision. This does not determine runtime quality, but it leaves more of the multi-module release process dependent on maintainer procedure.

The single GitHub Actions workflow tests Go 1.23, 1.24, and 1.25 and includes a race job. Its commands operate through go.work, which omits the k8smount, kiln, nats, and vault providers as well as the examples module. The NATS and Kubernetes mount providers have local tests but are outside that workspace run.

Test coverage is uneven among the provider modules. Thirteen of the twenty provider modules have no local _test.go files: appconfig, azkeyvault, basicflag, confmap, consul, etcd, file, fs, kiln, posflag, rawbytes, s3, and vault. The separate tests module exercises basicflag, confmap, posflag, rawbytes, file, and fs, including file loading, watching, symlink replacement, filesystem reads, merging, and concurrent access. The other seven modules in this group have neither local tests nor coverage from that behavioral suite. Users selecting one of those remote providers should test its failure and reload behavior against the deployed service.

Viper's age and integration with Cobra remain practical strengths. The pinned source retains a 2014 copyright header, a large behavioral test suite, and APIs shaped by years of use in established Go projects. Its conventions and existing examples can be more valuable than a smaller dependency graph for teams already operating within that ecosystem.

Choosing between them

I prefer koanf when source order must be application-defined, key case must be preserved, or dependency isolation is important. Its interfaces also suit systems with custom configuration sources because an adapter only needs to return bytes or a nested map. I would accompany that choice with explicit precedence tests, struct validation, and integration tests for any remote provider.

Viper is a reasonable choice when the application benefits from its established precedence hierarchy, Cobra binding, config discovery, automatic file reload, aliases, or safe file writing. Its larger main package and case-insensitive key model are concrete costs, while its remote dependency graph remains optional. Projects should use local Viper instances and account for the default AutomaticEnv unmarshalling behavior.

The selection depends on which work belongs in the library. koanf supplies a compact composition API and leaves more policy in application code. Viper implements more of the surrounding configuration workflow and consequently imposes more behavior and dependencies.