The configuration fatigue
You inherit a Go service that reads its settings from a YAML file. The previous developer hardcoded the database port in the source code. You decide to fix it. You pull in viper because it is famous. Suddenly your configuration keys are case-insensitive, environment variables silently override your file, and a typo in a key name returns a zero value instead of an error. The magic is convenient until it is not. You want a configuration library that tells you exactly what it is doing, fails loudly when something is missing, and lets you control the order of precedence. That is where koanf fits in.
What koanf actually does
koanf treats configuration as a simple map of strings to values. The core library does not know about files, environment variables, or command-line flags. It only knows how to merge maps and read typed values. You bring your own data sources and your own parsers. This modular design removes the guesswork. When viper loads a configuration, it runs a hidden cascade of defaults, environment lookups, and key normalization. koanf runs exactly the steps you wire together. Think of viper as a fully loaded kitchen appliance that pre-chops your vegetables. Think of koanf as a set of knives and cutting boards. You decide what gets chopped and in what order.
The library separates concerns into two interfaces. Providers fetch raw bytes. Parsers decode those bytes into a map[string]interface{}. The core koanf instance holds the merged map and provides type-safe getters. This separation means you can swap YAML for JSON, add a database provider, or chain multiple files without changing your reading logic. The merge behavior is deterministic. Later loads overwrite earlier keys at the same depth. You control the precedence by controlling the load order.
Configuration loading is a startup concern. You do not need context.Context here because the operation is synchronous and short-lived. You load the config once, validate it, and pass the resulting struct to your application. The convention is clear: load early, fail fast, and keep the configuration object immutable after startup. Run gofmt on your config structs before committing. The tool enforces consistent indentation and spacing, so you never argue about formatting in code reviews. Focus your energy on validation logic instead.
Configuration maps are cheap to pass around. You do not need pointers to the koanf instance unless you plan to mutate it at runtime. Most applications treat configuration as read-only after startup.
Configuration is plumbing. Wire it once, validate it strictly, and move on.
The minimal setup
Here is the simplest way to read a single YAML file into a koanf instance and extract a typed value.
package main
import (
"fmt"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
)
func main() {
// Initialize with a dot delimiter for nested key access
k := koanf.New(".")
// Load the file, parse it as YAML, and merge into the internal map
if err := k.Load(file.Provider("config.yaml"), yaml.Parser(), nil); err != nil {
panic(err)
}
// Read a nested key and convert it to an integer
port := k.Int("server.port")
fmt.Println("Listening on port", port)
}
The koanf.New(".") call creates an empty configuration store. The dot tells the library how to split nested keys when you call getters like k.Int("server.port"). Under the hood, koanf splits the string on the delimiter, walks the internal map level by level, and performs a type assertion on the final value. The Load method takes three arguments. The first is a provider that returns raw bytes. The second is a parser that turns those bytes into a map[string]interface{}. The third is an optional map of options, which you can leave as nil for standard behavior. If the file does not exist or the YAML is malformed, Load returns an error. The if err != nil block follows Go convention: handle the unhappy path immediately and make it visible. You do not swallow errors in configuration loading. A missing config file should stop the program before it starts accepting traffic.
When k.Int("server.port") runs, koanf splits the key on the dot, walks the internal map, finds the value, and attempts a type conversion. If the value is missing, it returns zero. If the value exists but cannot convert to an integer, it also returns zero. This silent fallback is intentional for optional settings, but it requires you to validate required fields explicitly.
Zero values are safe defaults for optional flags. They are silent killers for required settings.
Real-world configuration loading
Production services rarely rely on a single file. You usually want a base configuration, an environment-specific override, and a final pass for environment variables. koanf handles this by chaining Load calls. Each call merges into the existing map, with later calls winning on key collisions. The merge is shallow at the top level but recursive for nested maps. If you load a file with {"server": {"port": 8080}} and then load a second file with {"server": {"host": "localhost"}}, the result combines both keys under server. This predictable behavior lets you layer defaults, environment overrides, and secret injections without surprising collisions.
Here is the struct layout that holds the typed configuration.
// AppConfig holds the typed configuration for the service
type AppConfig struct {
Server ServerConfig `koanf:"server"`
Database DatabaseConfig `koanf:"database"`
}
type ServerConfig struct {
Port int `koanf:"port"`
Host string `koanf:"host"`
}
type DatabaseConfig struct {
DSN string `koanf:"dsn"`
}
The koanf struct tags map YAML keys to Go fields. If a tag is missing, koanf falls back to case-insensitive field matching. Public fields start with a capital letter so the reflection-based unmarshaler can access them. Private fields remain lowercase and are skipped during unmarshaling. This follows the standard Go visibility rule: capitalization controls export, not access modifiers.
Here is the loading sequence that layers multiple sources.
func loadConfig() (*AppConfig, error) {
k := koanf.New(".")
// Base configuration from a static file
if err := k.Load(file.Provider("config.yaml"), yaml.Parser(), nil); err != nil {
return nil, fmt.Errorf("failed to load base config: %w", err)
}
// Override with environment-specific file if it exists
if _, err := os.Stat("config.local.yaml"); err == nil {
if err := k.Load(file.Provider("config.local.yaml"), yaml.Parser(), nil); err != nil {
return nil, fmt.Errorf("failed to load local config: %w", err)
}
}
// Environment variables take final precedence
if err := k.Load(env.Provider("APP_", ".", nil), nil, nil); err != nil {
return nil, fmt.Errorf("failed to load env vars: %w", err)
}
var cfg AppConfig
// Unmarshal the merged map into a typed struct
if err := k.Unmarshal("app", &cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
return &cfg, nil
}
The env.Provider("APP_", ".", nil) call scans os.Environ for keys starting with APP_. It strips the prefix and uses the dot delimiter to match nested struct fields. APP_SERVER_PORT=8080 becomes server.port in the map. The Unmarshal method maps the flat key-value pairs to your struct using the koanf struct tags. It uses reflection to walk the struct fields, read the tags, and populate values from the internal map.
Notice the error wrapping pattern. fmt.Errorf("...: %w", err) preserves the original error chain. This follows Go convention: wrap errors at the boundary where you add context, and let the caller decide how to handle them. The function returns a pointer to the config struct because structs are passed by value in Go, and returning a pointer avoids copying the entire configuration tree. The convention "accept interfaces, return structs" applies here too. You return a concrete *AppConfig so callers know exactly what they are working with.
Configuration loading is a one-time operation. Run it at the top of main, validate the required fields, and pass the result to your router or worker pool. Do not reload configuration on every request. The overhead of parsing YAML and merging maps is unnecessary for static settings. If you need hot-reloading, run the loader in a background goroutine and publish updates through a channel. Always close the channel when the application shuts down to prevent goroutine leaks.
Return the concrete struct. Let the type system enforce what your service actually needs.
Where things go sideways
koanf is predictable, but it will not save you from bad data. The most common issue is silent zero-value fallbacks. If you request k.Int("missing.key"), you get 0. If you request k.String("missing.key"), you get "". This is fine for optional flags. It is dangerous for required settings like database credentials. You must validate required fields after loading. Check for empty strings or zero values and return an error before starting the server. A simple Validate() method on your config struct keeps this logic centralized and testable.
Type conversion failures also return zero values. If your YAML contains port: "not_a_number" and you call k.Int("port"), koanf returns 0 without panicking. The compiler will not catch this at build time. You get a runtime mismatch that only surfaces when the application tries to bind to port zero. Add explicit validation logic or use a library like go-playground/validator on your config struct after unmarshaling. Validation should happen immediately after Unmarshal, not scattered across your application.
Key collisions cause subtle bugs when you chain providers. If your YAML file defines server.port and your environment variable defines APP_SERVER_PORT, the environment variable wins because it loads last. If you accidentally load the environment provider before the file provider, the file overwrites the environment variable. The order of Load calls dictates precedence. Write the order explicitly in comments so future maintainers understand the hierarchy.
The compiler rejects missing imports with undefined: koanf or undefined: yaml. If you forget to pass the parser to Load, you get cannot use nil as type parser.Parser in argument. These errors are straightforward. Fix the import or pass the correct parser instance. koanf requires the parser to be non-nil when loading structured formats. You can pass nil for providers that already return parsed maps, but file and environment providers need a parser to decode raw bytes.
Configuration errors should fail the process early. Do not recover from a missing database DSN with a default value. A default database connection is a security and operational hazard. Let the program exit with a clear error message. The worst configuration bug is the one that silently connects to the wrong environment.
Fail fast at startup. A crashed process is easier to debug than a silently misconfigured one.
Picking your configuration tool
Use koanf when you want explicit control over configuration precedence and type safety. Use viper when you prefer automatic environment variable mapping, case-insensitive keys, and built-in defaults. Use the standard library flag package when your tool only needs a handful of command-line arguments. Use a custom loader when your configuration lives in a database or requires complex validation logic that third-party libraries cannot express.
Pick the tool that matches your precedence needs. Explicit wiring beats hidden magic.