Managing Environment Variables for Local Development in Go
12 min read

Managing Environment Variables for Local Development in Go

Almost every Go application needs configuration — server port, database credentials, API keys, feature flags — and almost every team starts the same way: os.Getenv scattered across various places in the code. This approach works fine while the application is small, but once the number of configuration variables grows and the application needs to run in many environments (local, staging, production), problems start to appear — env vars that are forgotten to be validated, inconsistent defaults, or config that’s hard to test. This article discusses how Go handles configuration starting from the most basic level, then gradually climbs to more scalable patterns: precedence between config sources, mapping to structs with reflection, startup validation, up to considerations for when you need libraries like Viper and secret managers in production.

Reading Environment Variables in Go

Go provides the built-in os package for reading environment variables, without any external dependencies. This is the simplest starting point, and it’s important to understand the difference between its two main functions before moving into more complex patterns.

package main

import (
	"fmt"
	"os"
)

func main() {
	dbHost := os.Getenv("DB_HOST")
	fmt.Println("DB Host:", dbHost)
}

os.Getenv always returns a string — empty if the variable doesn’t exist. The problem is that an empty string is ambiguous: was the env var deliberately set to an empty string, or was it never set at all? For cases where this difference matters, Go provides os.LookupEnv.

// ANTI-PATTERN: can't distinguish "not set" from "set to an empty string"
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
	panic("DB_HOST is not set") // could be wrong; it might genuinely be set to ""
}

// CORRECT: LookupEnv distinguishes explicitly with a second boolean
dbHost, ok := os.LookupEnv("DB_HOST")
if !ok {
	panic("DB_HOST is not set")
}

Use os.LookupEnv for configuration that must exist, and os.Getenv with a manual fallback for optional configuration:

port := os.Getenv("PORT")
if port == "" {
	port = "8080"
}

.env Files for Local Development

Go doesn’t natively read .env files — this is often a surprise for developers previously accustomed to Node.js or Python, where the ecosystems more often include this support by default. For local development, the most commonly used library is github.com/joho/godotenv.

go get github.com/joho/godotenv
# .env
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=secret
PORT=8080
package main

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	if err := godotenv.Load(); err != nil {
		log.Println(".env file not found, fallback to environment")
	}

	port := os.Getenv("PORT")
	log.Println("App running on port", port)
}

godotenv.Load() reads the .env file then injects its contents into the current process’s environment variables — after that, the calling code keeps using os.Getenv as usual, without any special API. This is why this pattern feels natural: .env is just an additional source, not a separate config mechanism.

.env files are only for local development. Never deploy a .env file to production — sensitive credentials stored as a plain file are far more vulnerable than env vars injected directly by the deployment platform or a secret manager.

Add .env to .gitignore from the start of the project, and provide .env.example as documentation of which variables are needed — without real sensitive values:

# .env.example
DB_HOST=
DB_USER=
DB_PASSWORD=
PORT=8080

Precedence: Flag vs Env vs File vs Default

Once an application has more than one config source — command-line flags, environment variables, .env files, and default values — you need a clear rule about which source wins when the same value appears in several places at once. Without an explicit rule, debugging config that “doesn’t behave as expected” becomes very confusing.

The convention commonly used in the Go ecosystem (and aligned with the 12-Factor App) is the following precedence, from highest to lowest:

flowchart TD
    A[Command-line flag] -->|highest priority| E[Final value]
    B[Environment variable] --> E
    C[Config file .env / .yaml] --> E
    D[Default value in code] -->|lowest priority| E
    A -.override.-> B
    B -.override.-> C
    C -.override.-> D

The reason this order makes sense: flags are usually set explicitly by the person running the binary at that moment, so they’re most specific to the context. Environment variables suit per-deployment configuration (containers, CI). Config files suit defaults shared across the whole team. Defaults in code are the last safety net.

func resolvePort(flagPort string) string {
	// 1. An explicit flag wins if set
	if flagPort != "" {
		return flagPort
	}
	// 2. Environment variable as the next source
	if envPort := os.Getenv("PORT"); envPort != "" {
		return envPort
	}
	// 3. Default as the last safety net
	return "8080"
}
Not every application needs all four layers at once. A small CLI tool might be fine with flag + default. A service running in a container usually needs just env var + default. Add a new layer only when the need is real, not because “it might be needed later”.

Mapping Config to a Struct

Scattering os.Getenv in many places makes configuration hard to track — there’s no single place showing all the variables the application needs. A neater pattern is mapping all env vars into one struct at the start, then passing that struct around as a dependency to other parts of the application.

type Config struct {
	Port       string
	DBHost     string
	DBUser     string
	DBPassword string
}

func LoadConfig() Config {
	return Config{
		Port:       getEnv("PORT", "8080"),
		DBHost:     os.Getenv("DB_HOST"),
		DBUser:     os.Getenv("DB_USER"),
		DBPassword: os.Getenv("DB_PASSWORD"),
	}
}

func getEnv(key, fallback string) string {
	if value := os.Getenv(key); value != "" {
		return value
	}
	return fallback
}

This pattern is already far better than os.Getenv scattered everywhere, but it still has one problem: every new struct field means one new manual code line in LoadConfig. For structs with 5-10 fields this is still manageable, but once the count reaches dozens — common in applications integrated with many external services — this manual pattern becomes repetitive and typo-prone (for example the struct field name not syncing with the env var name being read).


Struct Tags and Reflection for Automatic Config

The solution to the repetition problem above is describing the env var mapping directly in struct tags, then using reflection to fill the struct automatically at runtime. This is the pattern used by popular libraries like caarlos0/env, and understanding how it works helps you decide when it’s worth writing your own versus when a library is enough.

type Config struct {
	Port       string `env:"PORT" envDefault:"8080"`
	DBHost     string `env:"DB_HOST" envRequired:"true"`
	DBUser     string `env:"DB_USER" envRequired:"true"`
	DBPassword string `env:"DB_PASSWORD" envRequired:"true"`
}
flowchart TD
    A[Config struct with tags] --> B[reflect.TypeOf to read each field]
    B --> C{env tag present?}
    C -- No --> D[Skip field]
    C -- Yes --> E[os.LookupEnv with the name from the tag]
    E --> F{Found?}
    F -- Yes --> G[Set the value to the field via reflect.Value]
    F -- No --> H{envRequired true?}
    H -- Yes --> I[Return error]
    H -- No --> J[Use envDefault]

A simple implementation of your own version looks like this, to understand the mechanism before deciding to use a library:

package config

import (
	"fmt"
	"os"
	"reflect"
)

func Load(cfg interface{}) error {
	v := reflect.ValueOf(cfg).Elem()
	t := v.Type()

	for i := 0; i < t.NumField(); i++ {
		field := t.Field(i)
		envKey := field.Tag.Get("env")
		if envKey == "" {
			continue
		}

		value, ok := os.LookupEnv(envKey)
		if !ok {
			if def := field.Tag.Get("envDefault"); def != "" {
				value = def
			} else if field.Tag.Get("envRequired") == "true" {
				return fmt.Errorf("missing required env var: %s", envKey)
			}
		}

		v.Field(i).SetString(value)
	}
	return nil
}
// Usage
var cfg Config
if err := config.Load(&cfg); err != nil {
	log.Fatal(err)
}
Reflection in Go has a performance overhead compared to direct field access, and its errors are only detected at runtime, not compile time. For config that’s only read once at startup, this trade-off is usually fine — but don’t use a reflection pattern for something called repeatedly in the application’s hot path.

Config Validation at Startup

Whether using the manual or reflection pattern, one principle is equally important: fail fast — the application should stop with a clear error at startup if important configuration isn’t available, rather than crashing mid-runtime while a request is being processed.

required := []string{
	"DB_HOST",
	"DB_USER",
	"DB_PASSWORD",
}

for _, key := range required {
	if os.Getenv(key) == "" {
		log.Fatalf("Missing required env var: %s", key)
	}
}

For validation more complex than just “exists or not” — for example the port must be a valid number, or the URL must have the right format — add a Validate() method to the config struct:

func (c Config) Validate() error {
	if _, err := strconv.Atoi(c.Port); err != nil {
		return fmt.Errorf("PORT must be a number, got: %s", c.Port)
	}
	if c.DBHost == "" {
		return fmt.Errorf("DB_HOST is required")
	}
	return nil
}
cfg := LoadConfig()
if err := cfg.Validate(); err != nil {
	log.Fatalf("Invalid config: %v", err)
}

With explicit validation at startup, you move the possibility of errors from “discovered by users in production when the feature is used” to “discovered by developers/CI the first time the deployment runs” — this detection time difference is often the distinction between a major incident and a typo fixed within minutes.


When You Need Viper

The struct tag + reflection pattern above already solves many problems, but there are scenarios where rewriting this mechanism yourself is no longer worth it — especially if the application needs to read from many formats at once (env, YAML, JSON, remote config servers) or needs hot-reload when the config file changes. That’s where github.com/spf13/viper comes in as a popular choice in the Go ecosystem.

package main

import (
	"log"

	"github.com/spf13/viper"
)

func main() {
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	viper.AutomaticEnv()

	viper.SetDefault("port", "8080")

	if err := viper.ReadInConfig(); err != nil {
		log.Println("Config file not found, relying on env vars and defaults")
	}

	port := viper.GetString("port")
	dbHost := viper.GetString("db_host")

	log.Println("Port:", port, "DB Host:", dbHost)
}

Viper automatically handles precedence between sources (flag, env, file, default) without you needing to write that logic yourself, and supports many config file formats at once.

Aspectos + manual structCustom reflectionViper
External dependencyNoneNoneYes
Supports YAML/JSON filesNoNo (needs to be added yourself)Yes, native
Automatic precedenceManualManualAutomatic
Config hot-reloadNoNoYes
Setup complexityLowMediumMedium–high
Good forSmall apps, CLI toolsMedium apps without wanting extra dependenciesLarge apps, multi-environment, needing config source flexibility
Don’t use Viper just because it’s popular. If your need is only reading a dozen env vars with simple defaults, the struct tag + reflection pattern (or even a manual struct) is enough and easier for the team to understand without needing to learn Viper’s API.

Secret Managers for Production

The .env and regular env var patterns are enough for local development, but in production — especially for credentials like database passwords, API keys, or private keys — this approach has limitations. Env vars injected via the deployment platform can still leak through process logs, memory dumps, or accidental access to the CI/CD dashboard. For these needs, secret managers like AWS Secrets Manager, HashiCorp Vault, or Google Secret Manager provide an additional layer: encryption at rest, audit logs of who accessed which secret when, and automatic rotation without redeploying the application.

sequenceDiagram
    participant App as Go Application
    participant SM as Secret Manager
    participant DB as Database
    App->>SM: Request DB credentials at startup
    SM-->>App: Encrypted credentials + audit log recorded
    App->>DB: Connect using the credentials
    Note over SM: Periodic credential rotation<br/>without needing to redeploy the app

The integration pattern is generally: the application reads one env var containing a reference (for example a secret name or ARN), then calls the secret manager SDK to resolve the actual value at startup — not storing real credentials in env vars.

// Conceptual approach, not a complete SDK implementation
secretName := os.Getenv("DB_PASSWORD_SECRET_NAME")
dbPassword, err := secretManagerClient.GetSecret(ctx, secretName)
if err != nil {
	log.Fatalf("Failed to fetch secret: %v", err)
}
Never commit real credentials to a repository — whether through a .env file, hardcoding in code, or a git-tracked YAML config file. Once a credential enters git history, it must be considered permanently leaked even if the commit is later deleted, because git history remains searchable.

Testing Code That Depends on Config

Code that directly calls os.Getenv in the middle of business logic is hard to test, because tests become dependent on the environment variables of the process running the test — and this environment can differ between developer machines and CI. Go 1.17 and above provides t.Setenv specifically for this problem.

// ANTI-PATTERN: depends on real env vars during tests, needs manual setup
func TestLoadConfig(t *testing.T) {
	os.Setenv("PORT", "9090")
	cfg := LoadConfig()
	if cfg.Port != "9090" {
		t.Errorf("expected 9090, got %s", cfg.Port)
	}
	os.Unsetenv("PORT") // easy to forget, can leak into other tests
}

// CORRECT: t.Setenv automatically cleans up after the test finishes
func TestLoadConfig(t *testing.T) {
	t.Setenv("PORT", "9090")
	cfg := LoadConfig()
	if cfg.Port != "9090" {
		t.Errorf("expected 9090, got %s", cfg.Port)
	}
	// no manual Unsetenv needed, Go automatically restores the previous value
}

An even better approach for long-term testability is injecting Config as a dependency, rather than reading env vars directly inside the function being tested:

// ANTI-PATTERN: the function reads env vars directly, hard to test with different values
func ConnectDB() (*sql.DB, error) {
	host := os.Getenv("DB_HOST")
	return sql.Open("postgres", host)
}

// CORRECT: Config is injected as a parameter, easy to test with any value
func ConnectDB(cfg Config) (*sql.DB, error) {
	return sql.Open("postgres", cfg.DBHost)
}
func TestConnectDB(t *testing.T) {
	cfg := Config{DBHost: "localhost:5432"}
	db, err := ConnectDB(cfg)
	// no need to touch env vars at all
}

With dependency injection like this, tests no longer depend on the global environment variable state — each test can provide different config values explicitly and stay isolated from each other.


Strategy Summary Table

NeedRecommended Approach
Reading one or two simple env varsos.Getenv / os.LookupEnv directly
Local development without manual exports.env file + godotenv
Many config sources with clear priorityExplicit precedence: flag > env > file > default
Dozens of env vars with defaults & requiredStruct tags + reflection, or a library like caarlos0/env
Config validation before the app runsValidate() method called at startup, fail fast
Multi-format config (YAML/JSON/env) + hot-reloadViper
Sensitive credentials in productionSecret manager (AWS Secrets Manager, Vault, etc.)
Unit testing functions that depend on env varst.Setenv, or better: inject Config as a parameter

Summary

  • os.Getenv returns an empty string if the env var doesn’t exist; use os.LookupEnv when the difference between “not set” and “set to empty” matters.
  • .env files with godotenv make local development easier, but must not be used to store production credentials.
  • Set explicit precedence between config sources — commonly flag > environment variable > file > default — so application behavior isn’t ambiguous when several sources conflict.
  • Manually mapping env vars to a struct is enough for small apps, but struct tags + reflection are more scalable for dozens of variables.
  • Validating config at startup (fail fast) moves error detection from production runtime to the first deployment run.
  • Viper is worth using if the app needs multi-format config or hot-reload — for simple needs, the manual or custom reflection pattern is enough.
  • Sensitive credentials in production should be managed via a secret manager, not regular env vars, for audit logs and automatic rotation.
  • t.Setenv makes testing easier without forgetting cleanup, but injecting Config as a dependency parameter is the most testable pattern in the long run.

Portfolio