confkit vs Viper

Two different philosophies: confkit is typed and struct-first; Viper is dynamic and flexible. Here's how they compare in practice.

Quick Answer

TL;DR

confkit — typed generics API, built-in validation, secret redaction, 2 dependencies. Best for services that want config to be correct at startup.

Viper — dynamic string-keyed access, no validation, 30+ dependencies. Best for tools that need to read arbitrary keys at runtime or already use the Viper ecosystem.

Type Safety

The most fundamental difference: confkit uses Go generics to guarantee you get back the exact struct you defined. Viper returns interface{} under the hood, casting at read time.

confkit — typed at load
cfg, err := confkit.Load[Config](
    confkit.FromYAML("config.yaml"),
    confkit.FromEnv(),
)
// cfg.Port is int — guaranteed
// cfg.DB.URL is string — guaranteed
// All fields validated, defaults applied
Viper — typed at read
viper.SetConfigFile("config.yaml")
viper.AutomaticEnv()
viper.ReadInConfig()

port := viper.GetInt("port")
// Returns 0 if missing — silent failure
// No validation, no error

With confkit, a wrong type or missing required field is caught at startup in Load(). With Viper, silent zero values propagate into your running service.

Validation

Viper has no built-in validation. confkit validates every field automatically using struct tag rules.

confkit — declarative
type Config struct {
    Port int    `env:"PORT"
                  default:"8080"
                  validate:"min=1,max=65535"`
    Mode string `env:"MODE"
                  validate:"oneof=dev prod staging"`
    DB   string `env:"DATABASE_URL"
                  validate:"required" secret:"true"`
}
// Load() returns error if any rule fails
Viper — manual
// Viper has no validation tags.
// Write it yourself after every read:
port := viper.GetInt("port")
if port < 1 || port > 65535 {
    log.Fatal("port out of range")
}
mode := viper.GetString("mode")
if mode != "dev" && mode != "prod" {
    log.Fatal("invalid mode")
}

Error Messages

Viper silently returns zero values for missing keys. confkit returns structured errors with field name, rule, value, and source.

confkit — actionable
Invalid configuration:

  Port
    error: must be between 1 and 65535
    got:   99999
    source: env (PORT)

  DB
    error: field is required
    source: env (DATABASE_URL)
Viper — silent or raw
// Missing key: returns 0, "", false
// No error. Service starts broken.

// If you use Unmarshal:
// mapstructure decode error:
// 1 error(s) decoding:
// '' expected type 'int', got 'string'

Dependencies

ModuleconfkitViper
Core dependencies2 (yaml.v3, go-toml)30+ bundled
Cloud sourcesseparate opt-in modulesbundled in core
fsnotify (file watching)only if using watcheralways included
mapstructurenot usedcore dependency
castnot usedcore dependency

Viper bundles everything — even if you never use remote config, encryption, or file watching, the dependencies ship. confkit's cloud integrations (Vault, AWS, Kubernetes) are separate go gets.

Secret Redaction

confkit — automatic
type Config struct {
    APIKey string `env:"API_KEY" secret:"true"`
}
// Error output: API_KEY=*** (redacted)
// Dump output: API_KEY=*** (redacted)
// Log output:  API_KEY=*** (redacted)
Viper — not supported
// Viper has no secret redaction.
// Passwords appear in plain text
// in logs, errors, and debug output.
//
// You must handle this yourself.

Hot Reload

confkit
watcher, err := confkit.LoadWithWatcher[Config](
    sources,
    func(cfg Config, err error) {
        if err != nil {
            // validation failed — keep old config
            return
        }
        // cfg is validated and typed
        atomic.StorePointer(&current, cfg)
    },
)

Re-runs full load + validation on change. Bad config rejected before callback fires.

Viper
viper.WatchConfig()
viper.OnConfigChange(func(e fsnotify.Event) {
    // Fires on any change
    // No validation
    // You re-read values manually
    port := viper.GetInt("port")
})

Fires on file change. No re-validation. Invalid config can propagate to a running service.

Full Comparison Table

FeatureconfkitViper
API styleGeneric Load[T] → typed structString-keyed GetInt / GetString
Compile-time type safety
Built-in validation✅ struct tags❌ manual
Defaultsdefault:"..." tag⚠️ SetDefault() imperative
Secret redactionsecret:"true" tag
Human-readable errors✅ field + rule + source❌ silent zeros or raw errors
YAML / JSON / TOML
Environment variables
CLI flags✅ (pflag)
Vault✅ confkit/vault✅ viper/remote
AWS SSM / Secrets✅ confkit/aws⚠️ partial
Kubernetes ConfigMap✅ confkit/k8s
Consul / etcd✅ opt-in modules✅ bundled
Hot reload✅ with re-validation✅ no re-validation
String interpolation
Schema generation✅ JSON Schema + Markdown
Core dependencies230+
Go version1.25.0+ (generics, range-over-func)1.17+

When to Choose

Choose confkit if…

  • You want config validated and typed at service startup
  • You need secret redaction in errors and logs
  • You care about dependency footprint
  • You want defaults and validation in struct tags, not imperative code
  • You load from multiple sources (env + YAML + Vault + K8s)
  • You want human-readable errors when deployment config is wrong

Choose Viper if…

  • You need to read arbitrary keys dynamically at runtime
  • You have an existing large Viper codebase
  • You need the broader Viper ecosystem and community plugins
  • You target Go 1.17 and below (pre-generics)
  • You need pflag integration specifically