Apr 13, 2025·8 min read

Go config libraries for defaults, validation, and secrets

Go config libraries compared for defaults, validation, and secret loading, with clear notes on where each tool fits and where wrappers still creep in.

Go config libraries for defaults, validation, and secrets

Why plain env parsing gets messy

os.Getenv looks fine when a service has five settings. A month later, config reads sit in main.go, database setup, HTTP server code, and a background worker. Each package grabs its own values, picks its own fallback, and parses text in its own way. That is how one app ends up with three different names for the same timeout.

The worst part is timing. os.Getenv("PORT") returns an empty string when a value is missing, not an error. The program keeps going until some later step fails. Maybe the server binds to port 0. Maybe the database client gets an empty DSN and crashes after startup. Those bugs waste time because the real problem sits far away from the failure.

Some settings also stop being simple strings very quickly. A service often needs:

  • durations like 5s or 2m
  • lists like allowed origins or feature flags
  • nested settings for database, cache, and SMTP
  • secrets that should not live in plain env at all

Teams usually patch around this with small helpers. One function splits commas. Another parses ints. A third fills defaults. Then someone adds validation tags, but only on one struct. Soon the app has a homemade config layer nobody planned to build.

A small API can show the problem fast. Say it needs a Postgres URL, Redis address, request timeout, log level, and a list of allowed origins. If each part of the app parses its own setting, one package trims spaces and another does not. One default is 30s, another is 0, which means no timeout at all. The code still compiles, but startup behavior gets fuzzy.

That is why people start looking at Go config libraries. The goal is not more abstraction for its own sake. The goal is simpler rules: define config once, load it once, validate it early, and keep secret loading separate from random string parsing. Fewer wrappers usually means fewer surprises.

What this comparison checks

Good config code does four jobs well: it loads values, fills sensible defaults, rejects bad input, and keeps secrets out of plain config when it can. Many Go config libraries handle only part of that, so the comparison focuses on where each one stops and where your team still needs extra code.

The first check is basic but easy to miss: how a library treats missing values. Some tools let you set defaults in struct tags. Others want defaults in code or in a file. That difference matters. If a port is missing, a clean default is fine. If a database host is missing, the app should fail fast.

Type parsing matters just as much. A library should turn strings into durations, booleans, slices, nested structs, and numbers without weird surprises. It should also make a clear difference between "missing", "empty", and "invalid". Those three cases often get mixed together, and that is where config bugs start.

The comparison also checks where values come from:

  • env vars only
  • files only
  • both files and env, with a clear order of precedence
  • optional support for flags or remote stores when a team needs them

That source model affects daily work more than people expect. A small service may do fine with env only. A larger service usually wants a file for structure and env vars for overrides.

Secrets are a separate check on purpose. Reading config and fetching secrets are related, but they are not the same job. A good setup often stores non-secret settings in normal config, then pulls passwords, API tokens, or private keys from a secret manager at runtime. If a library mixes those concerns poorly, teams end up with custom wrappers anyway.

One more test is honesty about validation. Many tools can parse types and mark fields as required. Fewer can enforce real rules, like "if debug is false, a telemetry endpoint must exist" or "TLS mode needs both cert and key paths". For those checks, most teams still need a small validation layer of their own.

Struct-first env loaders

If your service reads a dozen env vars, plain os.Getenv calls get old fast. A struct-first loader gives you one Config type, one place for defaults, and one place to see what the app needs before startup.

A small Go service might need PORT, LOG_LEVEL, REQUEST_TIMEOUT, and DATABASE_URL. When those values live in a struct, code review gets easier because the whole setup sits in one file instead of being scattered across handlers and init code.

Among Go config libraries, this group is often the easiest place to start:

  • kelseyhightower/envconfig keeps things direct. You map env vars to struct fields with tags, and you can mark fields as required so the app fails early instead of running with half its config missing.
  • caarlos0/env is a good fit when you want defaults right in the tags. For a small service, that keeps setup short and readable, and it cuts down on custom wrapper code.
  • sethvargo/go-envconfig helps when your config is more than strings and ints. Its decoding hooks make custom types easier, so durations, slices, URLs, or your own enum-like types do not turn into a pile of manual parsing.

The big win is not magic. It is clarity. One Config struct becomes a checklist for what the app needs, what can fall back to a default, and what must exist before the process starts.

These tools also have a clear limit, and that limit matters. They load values from the environment. They do not fetch secrets from a secret store for you, rotate credentials, or decide when a secret should refresh.

That means a database password still has to come from somewhere else first. You might inject it into the process environment at deploy time, or load it from a secret client before the rest of your app starts.

If you only need env-based config, struct-first loaders are hard to beat. They keep config boring, and boring is exactly what you want when production depends on it.

Loaders that mix files and env

Among Go config libraries, these three come up a lot when one service needs a local file for development and env vars in production. The tricky part is not reading values. It is making override rules clear enough that nobody has to guess why the app picked one port, one database host, or one feature flag.

cleanenv is the simplest of the three. You define one struct, add tags, and load a file plus env vars into that same struct with very little setup. For small and mid-sized services, that feels nice because the config shape lives in one place. Nested config works well with nested structs, and env values can still override file values. The tradeoff is that deep config trees can get a bit repetitive because you tag many fields by hand.

Viper can pull from more places, and that flexibility is why many teams start with it. It can read files, env vars, flags, and defaults, and it handles nested settings through dotted paths and unmarshalling into structs. The problem shows up later. If a larger app mixes package-level globals, automatic env binding, and defaults scattered across files, people stop knowing which source wins. Viper is safer when you create your own instance instead of leaning on the global one.

Koanf sits in a middle spot, but its merge behavior is easier to follow. You load providers in order, and the order is the rule. File first, env second means env wins. That sounds small, but it matters when a service has nested settings like server.port, db.pool.max, and redis.addr. Koanf keeps those paths predictable, and it scales better when you later add another source.

A simple rule of thumb works well:

  • Pick cleanenv if you want one struct and very little ceremony.
  • Pick Viper if you already depend on it or need many input sources.
  • Pick Koanf if you care most about explicit merge order and fewer surprises.

For nested config, all three can do the job. The difference is how obvious the result feels when a value comes from two places. In a real service, that clarity saves time. If a startup runs one YAML file locally and injects env vars in CI/CD, cleanenv or Koanf usually stay easier to reason about than a Viper setup that grew without rules.

Helpers for defaults and validation

Choose a Simpler Stack
Map a safer config stack for Go services with Oleg's Fractional CTO help.

A clean config setup usually needs three separate jobs: load values, fill sane defaults, and reject bad input. When one package tries to do all of that, the code often gets messy. Among Go config libraries, a small combo often works better.

Confita handles the loading step. It reads from multiple backends and puts values straight into structs, which keeps startup code short. That matters when a service pulls some settings from env vars, others from a file, and a few sensitive values from a secret store.

creasty/defaults fits before or right after loading, depending on how you structure your app. It fills in safe values such as a default port, timeout, or log level. You define those defaults once in the config struct instead of writing a long chain of checks by hand.

After that, go-playground/validator does the strict part. It can reject missing fields, bad ranges, and invalid formats before the app serves a single request. If your database URL is empty, your retry count is negative, or an email field is malformed, the app fails fast with a clear error.

A simple flow looks like this:

  • start with the config struct
  • apply defaults
  • load real values with Confita
  • run validator and stop on errors

That order is easier to read than startup code full of one-off rules. You avoid pages of logic like "if timeout == 0, set 5 seconds" and "if port < 1 or port > 65535, return error" scattered across main.go.

A small service makes the benefit obvious. Say an API needs PORT, REQUEST_TIMEOUT, JWT_SECRET, and ADMIN_EMAIL. Defaults can set PORT=8080 and a 10 second timeout. Confita can load JWT_SECRET from a secret backend and the rest from env vars. Validator can then check that the port is valid, the timeout is positive, and the admin email looks like an email.

This split keeps each package honest. One loads, one fills blanks, one checks rules. That is easier to test, easier to change, and much less fragile than piling custom wrappers on top of raw env parsing.

Secret clients for runtime values

Config loaders and secret clients do different jobs. A loader reads settings like ports, timeouts, or feature flags. A secret client talks to a remote store and fetches live values such as database passwords, API tokens, or signing keys.

That split matters. If one package tries to do both, teams usually end up with messy rules for defaults, refresh, and errors. In most Go services, plain settings stay in a struct, while secrets come from a client at startup or on a timed refresh.

HashiCorp Vault's Go API fits teams that want central control and short-lived credentials. A service can fetch secrets during boot, then renew or reload them before they expire. That helps when a password rotates often and you do not want a full restart every time.

The AWS Secrets Manager SDK for Go fits teams that already run on AWS. IAM controls access, the secret can live next to the rest of the app, and rotation can tie into other AWS tools. If your systems already depend on AWS, this path is usually simpler than adding another secret store.

Failure rules that prevent bad surprises

Pick the rule before you write code.

  • If the app cannot run without a secret, stop during startup.
  • If only one feature needs it, turn off that feature and keep the rest running.
  • Cache the last good value, but give it a refresh time and an expiry.
  • Log the secret name and source on errors, never the secret value.

Caching helps, but it can hide problems. A short cache cuts latency and avoids rate limits, yet it can also keep an old password alive after rotation. Good code tracks when it fetched the secret, when it should refresh, and what the app should do if refresh fails.

Logs are where many teams slip. Do not print full config structs once they hold secrets. Redact sensitive fields, avoid %+v on structs, and make sure panic output cannot dump credentials into logs.

For most services, Go config libraries should still define the config shape and validate normal settings. Secret clients solve a different problem: they fetch sensitive values safely at runtime. Keeping those jobs separate usually leads to less wrapper code and fewer config bugs.

A simple stack for a real service

Review Your Go Config
Ask Oleg to review your Go config flow before small issues reach production.

Picture a small Go API that serves a few endpoints and only needs a port, two timeout values, and a couple of feature flags. That sounds simple, but plain env parsing spreads fast. One package reads PORT, another reads FEATURE_BETA, and soon every handler knows too much about startup settings.

A better pattern keeps one config struct and fills it once during boot. Among Go config libraries, this is the setup I like for small services because it stays easy to read:

  • set local defaults first
  • apply env overrides second
  • fetch the database password from a secret store last
  • validate the final struct before the server starts
type Config struct {
    Port         int           `env:"PORT" default:"8080" validate:"min=1,max=65535"`
    ReadTimeout  time.Duration `env:"READ_TIMEOUT" default:"5s"`
    WriteTimeout time.Duration `env:"WRITE_TIMEOUT" default:"10s"`
    FeatureBeta  bool          `env:"FEATURE_BETA" default:"false"`
    DBPassword   string        `validate:"required"`
}

This gives the app one source of truth. Your handlers get cfg Config or a small service object that already holds it. They do not touch env vars directly, and that removes a whole class of config bugs.

In local development, the app can start with almost no setup. Defaults cover the port and timeouts, and a developer can flip one feature flag with an env var if needed. If the service talks to a local database, the secret store can return a dev password, or the app can use a separate local profile.

In production, the same flow still makes sense. Env vars override the safe local defaults, so deploy-specific values stay outside the code. The app then asks AWS Secrets Manager, Vault, or another secret store for the database password right before opening the connection. One validation step checks the full struct and stops startup if something is wrong.

That is why this stack ages well. The boot code stays in one place, the runtime code stays clean, and new settings do not turn into a pile of custom wrappers.

How to choose your setup

Most Go services do not need a huge config layer. They need a small set of rules, a fixed order of trust, and errors that make sense when something goes wrong.

The first decision is not the library. It is the order your app should trust each source. If that order is fuzzy, the code gets confusing fast and people start guessing why one value beat another.

A simple order works well for most teams:

  • code defaults for safe fallback values
  • a config file for local and shared non-secret settings
  • environment variables for deploy-time overrides
  • a secret store for passwords, tokens, and API keys

This keeps normal settings easy to inspect and keeps secrets out of files. It also makes support work much easier. When a service starts with the wrong timeout or database host, you can trace where that value came from without reading a pile of custom wrappers.

Keep the tool set small. Pick one loader that fills structs, one helper for defaults if the loader does not handle them well, one validator, and one secret client. That is enough for most apps. When teams mix two loaders, three env helpers, and a homegrown secret layer, config stops being boring. That is usually a bad sign.

For a typical API, load everything once at startup. Validate it, fetch secrets, and stop the process if anything is wrong. Live reload sounds useful, but it adds shared state problems and hard-to-reproduce bugs. Add reload later only if you have a real setting that must change without a restart.

Tests matter more than a long feature list. Cover the failures you will actually see:

  • missing required values
  • bad types, like "abc" for a duration
  • invalid combinations, like TLS on with no certificate path
  • secret lookup failures or empty secret values

A small service gives a good example. You might load defaults for port and log level, read a YAML file for local database settings, let env vars override them in staging, and fetch the database password from a secret manager at startup. If one step fails, the app should exit with a direct error message. That setup is simple, easy to test, and hard to misuse.

Mistakes that cause config bugs

Make Startup Boring
Turn config loading into one clear step your team can trust.

Many config bugs start before the app even handles a request. A common one is loading config inside package init() functions. That feels tidy at first, but it spreads startup logic across the codebase and makes failures hard to trace. One package reads env, another reads a file, a third sets defaults, and nobody knows which value won.

Keep config loading in one place, usually near main(). Read it once, validate it once, then pass a typed config object into the rest of the app. Even the better Go config libraries become hard to trust if every package adds its own startup rules.

Too many defaults cause a different kind of damage. Defaults are great for local development, but they can quietly hide missing settings in staging or production. If PORT has a harmless default, fine. If DATABASE_URL, JWT_SECRET, or a cloud bucket name gets a default, you can ship a broken app that looks healthy for a while.

A good rule is simple: default safe, low-risk values, and fail fast on anything the service truly needs.

Secrets deserve their own handling. Teams often treat them like normal config, then print the whole config struct during startup or include raw values in error messages. That is how API keys end up in logs, alerts, and screenshots. Mask secrets by default. If validation fails, name the field, not the value.

A few habits prevent most of this:

  • Load config in one startup path, not in scattered init() code.
  • Use defaults for convenience, not to hide required settings.
  • Separate secret loading from ordinary settings when you log or debug.
  • Add wrappers only after the team hits a real repeated problem.

That last point matters more than people expect. Teams often stack custom helpers on top of Viper, Koanf, env parsers, validators, and secret clients before they have real pain. Six months later, nobody wants to touch config because the wrapper is now the product. Start plain. Add one thin layer only when the same problem shows up again and again.

If a small service has five required settings and two secrets, boring code usually wins.

Quick checks and next steps

A config stack is good when you can explain it in one minute and trust it at 2 a.m. If it takes custom wrappers, hidden fallback rules, and guesswork around secrets, it is already too complicated.

Most teams can spot problems with a short review. Use this checklist against your current setup, even if you already picked one of the Go config libraries above.

  • Keep the whole app config in one struct. If database settings live in one place, API settings in another, and feature flags in random helpers, bugs creep in fast.
  • Read every default and ask a blunt question: would I accept this in production? A default log level is fine. A default empty password or localhost database is not.
  • Make validation errors point to the exact field. "config invalid" is useless. "server.port must be between 1 and 65535" saves time.
  • Write clear rules for secrets. Decide which values must exist before startup, which ones can retry, how long they retry, and when the service must stop.
  • Add one startup test in CI that loads config the same way your service does. That single test catches missing env vars, bad tags, and broken defaults before deploy.

A small service usually needs less than people think. One struct, one loader, one validator, and one secret source is often enough. If your team keeps adding adapter code just to make config readable, that is a sign to simplify.

A practical next step is to boot the app locally with production-like settings and remove one custom layer. Then check the startup output. You should see clear defaults, clear overrides, and clear failures.

If you run a startup or a small team and want someone to review your Go config stack, Oleg Sotnikov can help as a Fractional CTO. This kind of review is usually short and concrete: tighten defaults, remove brittle env parsing, and make secret loading predictable before it turns into an outage.

Go config libraries for defaults, validation, and secrets | Oleg Sotnikov