confkit vs envconfig

envconfig does one thing: maps env vars to a struct. confkit does that plus validation, defaults, multi-source merging, and secret redaction.

Quick Answer

TL;DR

confkit — multi-source (env + YAML + cloud), validation tags, defaults tags, secret redaction. Replaces envconfig with zero boilerplate.

envconfig — environment variables only, stdlib-friendly, no validation or defaults. Best for dead-simple 12-factor services that outright refuse extra dependencies.

Sources

envconfig is env-only by design. confkit accepts sources in priority order — first match per field wins.

confkit — any source
cfg, err := confkit.Load[Config](
    confkit.FromFlags(),          // 1st priority
    confkit.FromEnv(),             // 2nd priority
    confkit.FromYAML("cfg.yaml"), // 3rd priority
    vault.FromVault(addr, auth, path),
)
envconfig — env only
var cfg Config
if err := envconfig.Process("", &cfg); err != nil {
    log.Fatal(err)
}
// Only reads os.Getenv()
// No files, no flags, no cloud

Defaults

confkit — in the struct
type Config struct {
    Host    string        `env:"HOST"    default:"localhost"`
    Port    int           `env:"PORT"    default:"8080"`
    Timeout time.Duration `env:"TIMEOUT" default:"30s"`
}
envconfig — post-process
type Config struct {
    Host    string        `envconfig:"HOST"`
    Port    int           `envconfig:"PORT"`
    Timeout time.Duration `envconfig:"TIMEOUT"`
}
// Manually set defaults after Process():
if cfg.Host == ""    { cfg.Host = "localhost" }
if cfg.Port == 0     { cfg.Port = 8080 }
if cfg.Timeout == 0  { cfg.Timeout = 30 * time.Second }

Validation

confkit — declarative tags
type Config struct {
    Port    int    `env:"PORT"    validate:"min=1,max=65535"`
    LogLevel string `env:"LOG_LEVEL" validate:"oneof=debug info warn error"`
    DBURL   string `env:"DATABASE_URL" validate:"required"`
}
envconfig — manual code
// envconfig has no validation.
// Write it yourself:
if cfg.Port < 1 || cfg.Port > 65535 {
    return fmt.Errorf("port %d out of range", cfg.Port)
}
validLevels := map[string]bool{}"debug":true, "info":true
if !validLevels[cfg.LogLevel] {
    return fmt.Errorf("invalid log level")
}

Error Messages

confkit
Invalid configuration:

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

  DATABASE_URL
    error: field is required
    source: env (DATABASE_URL)
envconfig
envconfig: required key DATABASE_URL
missing value

// No field context, no source info,
// no hint on how to fix it

Secret Redaction

confkit
type Config struct {
    DBPassword string `env:"DB_PASSWORD" secret:"true"`
}
// In errors:  DB_PASSWORD=*** (redacted)
// In dumps:   DB_PASSWORD=*** (redacted)
envconfig
// No secret support.
// envconfig will print plaintext passwords
// in error messages:
// envconfig: DB_PASSWORD=hunter2 invalid

Migrating from envconfig to confkit

Migration is mechanical — rename tags and change the call site:

Before (envconfig)
type Config struct {
    Port int    `envconfig:"PORT"`
    DB   string `envconfig:"DATABASE_URL" required:"true"`
}

var cfg Config
envconfig.Process("", &cfg)
After (confkit)
type Config struct {
    Port int    `env:"PORT" default:"8080" validate:"min=1,max=65535"`
    DB   string `env:"DATABASE_URL" validate:"required" secret:"true"`
}

cfg, err := confkit.Load[Config](confkit.FromEnv())

Full Comparison Table

Featureconfkitenvconfig
Environment variables
YAML / JSON / TOML files
CLI flags
Cloud sources✅ opt-in modules
Defaults via struct tagdefault:"..."❌ manual
Built-in validation✅ struct tags❌ manual
Secret redactionsecret:"true"
Human-readable errors⚠️ basic
Multi-source merging
Typed generics API⚠️ reflect
String interpolation
Core dependencies20 (stdlib)
12-factor compatible

When to Choose

Choose confkit if…

  • You want defaults declared in the struct, not in code
  • You need validation (required fields, ranges, enums)
  • You ever want to add a YAML file or Vault later
  • You need secrets to not appear in error logs
  • You want a single Load() call to do everything

Choose envconfig if…

  • You load config only from environment variables — always
  • You want zero non-stdlib dependencies
  • Your config is simple: no ranges, no enums, no cross-field validation
  • You're already using it and it covers your needs