Go reads environment variables with os.Getenv and os.LookupEnv. That is the right answer for a small service with a handful of config values. Past that, every team reaches for a library: godotenv for .env files in development, viper or koanf for layered config, and the standard library for the rest. The interesting thing about Go is that the standard library is good enough that the third-party library is a choice, not a requirement. The trap is the choice.
This post is the boring truth about Go environment variables. The first half is what the standard library does well. The second half is the libraries that fill the gaps, the one library that is overused, and the deploy-side version of the question that is more interesting than the local one.
The interesting thing about “Go env vars” is that the local answer is straightforward and the production answer is the one that actually matters. A team that gets the production answer right can skip most of the local complexity. A team that gets the local answer right and the production answer wrong will debug env-var-shaped bugs for years.
Table of contents
- The direct answer
- What os.Getenv actually does
- The LookupEnv gotcha
- The .env file debate
- The library tier: godotenv, koanf, viper
- The library to avoid: viper for a small service
- The deploy-side version
- The opinion this post is built on
- FAQ
The direct answer
For a small Go service:
package main
import (
"fmt"
"os"
)
func main() {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL is required")
}
// ...
}
That is the working baseline. The rest of the post is the cases where the baseline is not enough, the libraries that fill them, and the one library most teams reach for that they should not.
What os.Getenv actually does
os.Getenv(key) returns the value of the environment variable key, or an empty string if the variable is unset. The empty-string behavior is the source of most Go env-var bugs, because an empty string is also a valid value. There is no way to distinguish “the variable is set to an empty string” from “the variable is not set” using os.Getenv.
For most applications, that is fine. An empty PORT should default. An empty DATABASE_URL is a startup error. The two cases are different, and a small wrapper handles both:
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
That is the entire pattern most Go services need. The os.Getenv is the OS, the wrapper handles the default, and the rest of the code never has to think about it.
The pattern most teams skip is the “required” check. A service that needs DATABASE_URL should fail fast at startup if it is missing:
func mustGetenv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Fatalf("%s is required", key)
}
return v
}
A log.Fatal at startup is the right answer for a missing required variable. The service refuses to start, the platform restarts it, the platform’s health check fails, the platform pages the engineer. The engineer fixes the secret. The next deploy has the secret. The system was loud, not silent, and that is the right shape for a misconfiguration.
The LookupEnv gotcha
os.LookupEnv(key) returns the value and a boolean indicating whether the variable was set. That is the right answer for the cases where empty string and unset are different:
if value, ok := os.LookupEnv("FEATURE_X_ENABLED"); ok {
if value == "true" {
// ...
}
}
The pattern is the same as Python’s os.environ.get with a default, except that the Go version requires the explicit ok check. Most Go services do not need this distinction. The cases that do are: optional feature flags, optional third-party service credentials, optional observability sinks.
For a service with three or four required values and a handful of optional ones, os.Getenv plus a wrapper is the entire story. The trap is reaching for a library when the standard library is enough.
The .env file debate
.env files are a development convenience. They let a developer set environment variables without exporting them in the shell. Production should never read them. The disagreement is over development.
The case for .env files: the developer clones a repo, copies .env.example to .env, fills in the local values, and the service picks up the config. No shell exports, no .bashrc pollution, no per-developer onboarding script.
The case against .env files: the service has two code paths (one that reads the file, one that reads the environment), the file is sometimes checked in with secrets, and the production deploy never reads the file anyway.
The right answer for a Go service is to use godotenv in development only, behind a build tag or a flag:
//go:build dev
package main
import _ "github.com/joho/godotenv/autoload"
The //go:build dev tag means the import is only compiled into the dev build. The production binary has no .env file support, and the developer’s local secret cannot accidentally make it into production.
For a service that wants to avoid the dependency entirely, the alternative is to use a shell script in development:
# .envrc (with direnv) or a Makefile
set -a; source .env; set +a
go run ./cmd/api
set -a; source .env; set +a exports every variable in .env to the environment for the duration of the command. No Go code reads the file. The production binary has no .env knowledge. The local development flow is one make run or one direnv allow.
The godotenv library is fine, but the shell-script approach is the boring default that does not require a dependency. The boring default is the one that survives the team growing past three people.
The library tier: godotenv, koanf, viper
Three libraries cover most of the landscape. The right answer is the smallest one that does what the service needs.
godotenv is the .env file loader. It reads a file, sets the variables in the environment, and the standard library does the rest. The right answer for a service that wants .env files in development and nothing more.
koanf is the configuration library. It reads from many sources (env, file, flag, vault, etcd), merges them with a defined precedence, and exposes the result as a typed struct. The right answer for a service that has more than ten configuration values, multiple sources, or wants the configuration to be testable.
viper is the configuration library that became a framework. It does everything koanf does, plus YAML/JSON/TOML parsing, remote config stores, and live reloading. The right answer for a service that has all of those requirements, which is a small fraction of the services that reach for it.
The trap is viper. Most services that import viper use 5% of its features, and the 5% is the part that any of the smaller libraries does. The other 95% is dead code that the team has to maintain, that pulls in dependencies, and that makes the binary larger for no benefit. The right answer for a small Go service is os.Getenv plus a wrapper. The right answer for a service that needs layered config is koanf. The right answer for viper is rare and specific.
The library to avoid: viper for a small service
The overuse pattern:
import "github.com/spf13/viper"
func init() {
viper.SetConfigFile(".env")
viper.ReadInConfig()
viper.AutomaticEnv()
}
func getConfig(key string) string {
return viper.GetString(key)
}
The pattern is fine. The problem is what the service imports along with it. viper pulls in YAML, JSON, TOML, HCL, env, flag, remote, and several crypto dependencies. The binary is 10-20MB larger than the standard library equivalent. The team’s go.mod is harder to upgrade. The configuration logic is hidden inside viper.GetString, and the next developer who has to debug “where is this value coming from?” has to read the viper source to find out.
For a small Go service — a webhook receiver, a CLI tool, an API with a handful of endpoints — os.Getenv plus a small wrapper is the right answer. The wrapper is a 20-line file that the next developer can read in 30 seconds. The behavior is documented in the standard library. The dependency tree is empty. The binary is small.
For a service that needs more — multiple config sources, hot-reload, runtime config updates — koanf is the right answer. It does the same job as viper with a smaller surface area and fewer dependencies. The migration path from koanf to viper is easy; the migration path from viper to koanf is harder. Start small, grow only when needed.
The deploy-side version
The local answer is os.Getenv plus a wrapper. The production answer is more interesting, because production is where the secret values live and the env vars are injected.
The shape of a production Go deploy:
- The platform has a secret store. The values for
DATABASE_URL,API_KEY, andSESSION_SECRETlive in the secret store, not in the environment of the developer’s machine. - The platform injects the secret values as environment variables when it starts the container. The Go binary reads them with
os.Getenv. The values never appear in the build log, the image, or the source code. - The platform rotates the secrets on a schedule. The next deploy picks up the new values. The Go binary reads them at startup. No code change.
A platform that exposes the secret store as environment variables, rotates secrets on a schedule, and rebuilds the container on secret changes is the platform where env-var handling is a one-line problem. A platform that does not is the platform where the team writes a custom secret-loader library and debugs a custom secret-loader bug every six months.
The RunxBuild platform is built around that pattern. The Go service reads os.Getenv("DATABASE_URL"). The platform injects the value. The developer does not write a secret-loader library. The team does not debug secret rotation. The env var is a bridge between the platform and the code, and the bridge has no moving parts.
For a deeper look at the local development side, the deploy a Docker image guide covers the same pattern in the context of a Docker image with multi-stage build. For a sanity check on the deploy cost, the hosting cost calculator gives a real number to compare against.
The opinion this post is built on
Go’s standard library handles env vars the way the OS exposes them, and that is the right answer for most Go services. The trap is the “I need a library” instinct that comes from languages where the standard library is worse. Go’s os package is good. The wrapper that handles defaults and required-value checks is twenty lines. The total is the boring answer that does not pull in a dependency tree.
The boring answer is also the answer that survives the team growing. A wrapper that the next developer can read in 30 seconds is a wrapper that the next developer will understand. A library that the next developer has to read the source of to understand is a library that the next developer will misuse. The codebase that uses os.Getenv is the codebase that does not have a “where is this value coming from?” problem.
The library question is a real one, but the threshold is higher than most teams treat it as. Below ten configuration values and a single source, the standard library is the right answer. Above ten or with multiple sources, koanf is the right answer. viper is the right answer for the rare service that needs every feature it has, and that service is rarer than the viper import lines in go.mod files suggest.
The deploy side is where the discipline matters. A platform that injects secrets, rotates them, and rebuilds on changes turns the env-var question from a code problem into a platform feature. The team writes os.Getenv, the platform does the rest. That is the only honest return on a Go env-var investment that is worth more than the standard library.
FAQ
How do I read an environment variable in Go?
os.Getenv(key) returns the value or an empty string if unset. os.LookupEnv(key) returns the value and a boolean indicating whether it was set. For a service that needs defaults, write a small wrapper that handles the empty case. For a service that needs to distinguish “empty” from “unset,” use LookupEnv.
Should I use a .env file in Go?
In development, yes — it makes local onboarding easier. In production, no — the platform should inject the values from a secret store. The cleanest pattern is to use godotenv behind a build tag (//go:build dev) so the production binary has no .env file support. The alternative is a shell script (set -a; source .env; set +a; go run) that requires no Go dependency.
What is the best Go env var library?
For a small service, the standard library. For a service that needs layered config from multiple sources, koanf. For a service that needs every feature (YAML/JSON/TOML parsing, remote config, hot reload), viper. Most services that reach for viper should reach for koanf instead. The migration from koanf to viper is easy; the migration the other way is harder. Start small.
How do I handle a required env var that is missing?
log.Fatal at startup. The service refuses to start, the platform restarts it, the platform’s health check fails, and the engineer gets paged. Failing loud at startup is the right answer for a missing required value. The alternative — a service that starts with a missing value and crashes on the first request — is the wrong answer.
How do I handle env vars in a Go Docker container?
Read them with os.Getenv. The platform injects them at container start. The Dockerfile has no ENV line for credentials. The build log has no secrets. The image has no secrets. The application reads the values at startup. The pattern is the same as local development, with the platform playing the role of the shell’s environment.
Should I use os.Getenv or a config struct?
For a service with three to ten values, os.Getenv plus a wrapper is enough. For a service with more, define a config struct, read the values into it at startup, and pass the struct around. The struct makes the configuration testable, the field names are self-documenting, and the rest of the code does not have to know about env var names.