env.dev

Go Environment Variables: os.Getenv, godotenv & Viper

How to read, set, and manage environment variables in Go. Covers os.Getenv, os.LookupEnv, godotenv, Viper, envconfig, build-time variables, and testing.

By env.dev Updated

Go reads environment variables through two standard-library functions: os.Getenv("KEY") returns an empty string for a missing variable, and os.LookupEnv("KEY") returns a (string, bool) so you can tell "unset" from "set to empty". Every value is a string at the OS layer, so int, bool, and time.Duration conversion is on you unless a library like envconfig or viper does it. The sharp edge most teams hit: os.Setenv is not thread-safe — it wraps C's racy setenv, which is exactly why Go 1.17's t.Setenv refuses to run in a parallel test.

TL;DR

  • os.Getenv returns "" for both unset and empty; use os.LookupEnv when that distinction matters.
  • godotenv.Load() never overwrites a variable that already exists in the process environment — your .env edit is silently ignored. Use godotenv.Overload() to force it.
  • Env values are always strings: parse with strconv, or let envconfig/viper map them to typed struct fields.
  • Never call os.Setenv from goroutines or after t.Parallel() — it mutates process-global state through C's non-thread-safe setenv.

How do os.Getenv() and os.LookupEnv() differ?

The os package provides two ways to read environment variables. os.Getenv() returns the value or an empty string if the variable is not set. os.LookupEnv() returns both the value and a boolean indicating whether the variable exists, letting you distinguish between unset and set-but-empty.

go
package main

import (
	"fmt"
	"os"
)

func main() {
	// Returns "" if DATABASE_URL is not set — no way to tell if it was empty or missing
	dbURL := os.Getenv("DATABASE_URL")
	fmt.Println(dbURL)

	// Returns the value and a boolean — distinguishes unset from empty
	val, exists := os.LookupEnv("DATABASE_URL")
	if !exists {
		fmt.Println("DATABASE_URL is not set")
	} else {
		fmt.Println("DATABASE_URL =", val)
	}
}

Use os.LookupEnv() when you need to differentiate between a missing variable and one explicitly set to an empty string. Use os.Getenv() when an empty default is acceptable.

How do you set and unset environment variables?

os.Setenv() sets a variable for the current process and any child processes. os.Unsetenv() removes it. Both return an error on failure.

go
// Set a variable for the current process
if err := os.Setenv("APP_MODE", "debug"); err != nil {
	log.Fatal(err)
}

// Remove a variable
if err := os.Unsetenv("APP_MODE"); err != nil {
	log.Fatal(err)
}

// List all environment variables
for _, env := range os.Environ() {
	fmt.Println(env) // prints KEY=VALUE pairs
}

How do you load a .env file with godotenv?

The github.com/joho/godotenv package reads key-value pairs from a .env file and injects them into the process environment. Install it with go get github.com/joho/godotenv.

go
package main

import (
	"log"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	// Load .env from the current directory
	if err := godotenv.Load(); err != nil {
		log.Println("No .env file found")
	}

	// Load a specific file
	if err := godotenv.Load("/app/config/.env"); err != nil {
		log.Fatal(err)
	}

	// Load multiple files — later files do NOT override earlier values
	if err := godotenv.Load(".env", ".env.local"); err != nil {
		log.Fatal(err)
	}

	// Overload replaces existing values (like override mode)
	if err := godotenv.Overload(".env.local"); err != nil {
		log.Fatal(err)
	}

	// Read variables normally after loading
	dbURL := os.Getenv("DATABASE_URL")
	log.Println("DB:", dbURL)
}

Call godotenv.Load() before reading any variables, typically at the start of main(). See the .env guide for file syntax details.

Gotcha: Load() silently ignores existing values

godotenv.Load() will not overwrite a variable that is already set in the process environment — the README states it plainly: "Existing envs take precedence of envs that are loaded later." The classic symptom: you change a value in .env, restart the app, and nothing changes — because your shell, your Docker ENV, or your CI runner already exported that key. This is the correct default for twelve-factor apps (real platform config should win over a checked-in file), but it burns people who treat .env as the source of truth. When you genuinely want the file to win, call godotenv.Overload() instead — same signature, opposite precedence.

How does Viper handle configuration and env vars?

github.com/spf13/viper is a full configuration management library that reads from environment variables, config files (JSON, YAML, TOML), remote key-value stores, and command flags. It can bind env vars automatically using a prefix.

go
package main

import (
	"fmt"
	"log"

	"github.com/spf13/viper"
)

func main() {
	// Automatically prefix all env lookups with APP_
	viper.SetEnvPrefix("APP")

	// Bind specific keys to env vars: APP_PORT, APP_DEBUG
	viper.BindEnv("port")
	viper.BindEnv("debug")

	// Or bind all env vars automatically
	viper.AutomaticEnv()

	// Set defaults
	viper.SetDefault("port", 8080)
	viper.SetDefault("debug", false)

	// Read a config file alongside env vars (env takes priority)
	viper.SetConfigName("config")
	viper.SetConfigType("yaml")
	viper.AddConfigPath(".")
	if err := viper.ReadInConfig(); err != nil {
		log.Println("No config file found, using env and defaults")
	}

	fmt.Println("Port:", viper.GetInt("port"))
	fmt.Println("Debug:", viper.GetBool("debug"))
}

How do you bind env vars to structs with envconfig?

github.com/kelseyhightower/envconfig maps environment variables directly to struct fields using a prefix and struct tags. It handles type conversion automatically.

go
package main

import (
	"fmt"
	"log"
	"time"

	"github.com/kelseyhightower/envconfig"
)

type Config struct {
	Port           int           `envconfig:"PORT" default:"8080"`
	Debug          bool          `envconfig:"DEBUG" default:"false"`
	DatabaseURL    string        `envconfig:"DATABASE_URL" required:"true"`
	AllowedOrigins []string      `envconfig:"ALLOWED_ORIGINS" default:"localhost"`
	ReadTimeout    time.Duration `envconfig:"READ_TIMEOUT" default:"5s"`
}

func main() {
	var cfg Config
	// "APP" prefix means it reads APP_PORT, APP_DEBUG, APP_DATABASE_URL, etc.
	if err := envconfig.Process("APP", &cfg); err != nil {
		log.Fatal(err)
	}

	fmt.Printf("Port: %d, Debug: %v, DB: %s\n", cfg.Port, cfg.Debug, cfg.DatabaseURL)
	fmt.Printf("Origins: %v, Timeout: %s\n", cfg.AllowedOrigins, cfg.ReadTimeout)
}

The required:"true" tag causes envconfig.Process() to return an error if the variable is not set. Slices are split on commas by default, and time.Duration values are parsed natively.

Which approach should you pick?

For most services, start with the standard library plus a hand-written validating loader, and reach for a library only when its specific feature earns its place. The trade-offs:

ApproachType conversionBest when
os + strconvManualFew vars, zero dependencies, full control over validation messages
godotenvManual (loads strings only)Loading a local .env in dev; pairs with any reader
envconfigAutomatic via struct tagsConfig comes purely from env; want required/default in one struct
viperAutomatic via gettersNeed files (YAML/TOML), remote stores, or flags in one precedence chain

Opinionated take: do not pull in Viper just to read a dozen env vars. It historically hardcoded heavyweight remote-config dependencies (etcd, consul, gRPC) into its core — bloat severe enough that the lighter koanf project measured a trivial JSON-reading binary built with Viper at roughly 4x the size of the koanf equivalent (its docs cite Viper as 313% larger). Viper has since split remote config into an optional /remote submodule, but its global-singleton API still makes config awkward to inject into tests. envconfig or a plain struct loader keeps the surface small and the dependency graph honest.

How do you inject variables at build time with ldflags?

Go's linker flags (-ldflags) let you set string variables at compile time without environment variables at runtime. This is commonly used for version strings and build metadata.

go
package main

import "fmt"

// These are set at build time via -ldflags
var (
	Version   = "dev"
	CommitSHA = "unknown"
	BuildTime = "unknown"
)

func main() {
	fmt.Printf("Version: %s, Commit: %s, Built: %s\n", Version, CommitSHA, BuildTime)
}
go
// Build command — sets the variables at link time
// go build -ldflags "-X main.Version=1.2.3 -X main.CommitSHA=$(git rev-parse HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" -o myapp .

The -X flag sets a string variable in the specified package. The variable must be of type string and must be a package-level variable.

What is the config struct validation pattern?

A common Go pattern is to centralize configuration in a struct, load it once at startup, and validate all values before the application starts serving traffic.

go
package config

import (
	"errors"
	"os"
	"strconv"
)

type Config struct {
	DatabaseURL string
	Port        int
	Debug       bool
	SecretKey   string
}

func Load() (*Config, error) {
	port, err := strconv.Atoi(getEnvOrDefault("PORT", "8080"))
	if err != nil {
		return nil, errors.New("PORT must be a valid integer")
	}

	cfg := &Config{
		DatabaseURL: os.Getenv("DATABASE_URL"),
		Port:        port,
		Debug:       os.Getenv("DEBUG") == "true",
		SecretKey:   os.Getenv("SECRET_KEY"),
	}

	if err := cfg.validate(); err != nil {
		return nil, err
	}
	return cfg, nil
}

func (c *Config) validate() error {
	if c.DatabaseURL == "" {
		return errors.New("DATABASE_URL is required")
	}
	if c.SecretKey == "" {
		return errors.New("SECRET_KEY is required")
	}
	if c.Port < 1 || c.Port > 65535 {
		return errors.New("PORT must be between 1 and 65535")
	}
	return nil
}

func getEnvOrDefault(key, fallback string) string {
	if val, ok := os.LookupEnv(key); ok {
		return val
	}
	return fallback
}

How do you test with environment variables in Go?

Go 1.17 introduced t.Setenv(), which sets an environment variable for the duration of a test and restores the original value automatically when the test completes.

go
package config_test

import (
	"os"
	"testing"
)

func TestLoadConfig(t *testing.T) {
	// t.Setenv sets the variable and restores it after the test
	t.Setenv("DATABASE_URL", "postgres://localhost/testdb")
	t.Setenv("SECRET_KEY", "test-secret")
	t.Setenv("PORT", "3000")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if cfg.Port != 3000 {
		t.Errorf("expected port 3000, got %d", cfg.Port)
	}
}

func TestMissingRequiredVar(t *testing.T) {
	// Unset the variable to test validation
	t.Setenv("DATABASE_URL", "")
	t.Setenv("SECRET_KEY", "test-secret")

	_, err := Load()
	if err == nil {
		t.Fatal("expected error for missing DATABASE_URL")
	}
}

func TestGetenvFallback(t *testing.T) {
	// Verify LookupEnv-based fallback works
	os.Unsetenv("MY_VAR")
	val := getEnvOrDefault("MY_VAR", "default-value")
	if val != "default-value" {
		t.Errorf("expected default-value, got %s", val)
	}
}

t.Setenv() automatically calls t.Cleanup() to restore the previous value, so you never leak state between tests. It also panics if the test (or any parent test) has called t.Parallel() — the message reads testing: t.Setenv called after t.Parallel. That guard exists because environment access is process-global and not thread-safe: os.Setenv ultimately calls C's setenv, which can crash a concurrent getenv — including Go's own cgo DNS resolver. A later fix (issue #55128) tightened the check so it fires even for a non-parallel subtest running under a parallel ancestor. The practical rule: never mutate the environment from goroutines or parallel tests; build your config once at startup and pass it down as a struct.

When should you not use environment variables?

  • Compile-time constants — version numbers, feature toggles baked per build, or values that never change at runtime belong in const declarations or Go constants and iota, not the environment. Use -ldflags -X for build metadata you want in the binary without a runtime lookup.
  • Large or structured config — nested objects, lists of routes, or anything you would otherwise model as a struct read worse as flat APP_FOO_BAR_BAZ keys. A YAML or TOML file parsed by Viper is clearer and reviewable in version control.
  • Secrets in long-lived processes — env vars are visible to any code in the process, leak into os.Environ() dumps and crash reports, and are inherited by every child process. For high-value secrets prefer a secrets manager (Vault, AWS Secrets Manager) fetched at startup. See the environment variable security guide for the threat model.

Frequently Asked Questions

How do I read an environment variable in Go?

Call os.Getenv("KEY"), which returns the value or an empty string if the variable is unset. Use os.LookupEnv("KEY") when you need to distinguish a missing variable from one set to an empty string — it returns the value plus a boolean that is false only when the key is absent.

Why does my .env change not take effect with godotenv?

godotenv.Load() does not overwrite variables already present in the process environment, so if the key is exported in your shell, Docker ENV, or CI runner, the .env value is silently ignored. Run echo $KEY to confirm, or switch to godotenv.Overload() to make the file win.

How do I convert an environment variable to an int or bool in Go?

Every env var is a string, so parse it yourself: strconv.Atoi for ints, strconv.ParseBool for bools, time.ParseDuration for durations. Or bind the whole config to a struct with kelseyhightower/envconfig, which converts types automatically from struct tags.

Why does t.Setenv panic in a parallel test?

os.Setenv mutates process-global state through C's setenv, which is not thread-safe and can crash a concurrent getenv. testing.T.Setenv guards against this by panicking with "t.Setenv called after t.Parallel" if the test or any ancestor is parallel. Set env vars before t.Parallel(), or avoid parallel tests that touch the environment.

Should I use Viper or envconfig for Go configuration?

Use envconfig when your config comes purely from environment variables and you want a lightweight struct-tag mapping with required and default support. Use Viper when you also need config files (YAML/TOML/JSON), remote stores, or command-flag binding in one precedence chain.

References

Check your .env files for syntax errors with the env validator, read the .env guide for full dotenv syntax, or skim the Go cheat sheet for syntax at a glance.

Was this helpful?

Frequently Asked Questions

How do I read an environment variable in Go?

Call os.Getenv("KEY"), which returns the value or an empty string if the variable is unset. Use os.LookupEnv("KEY") when you need to distinguish a missing variable from one set to an empty string — it returns the value plus a boolean that is false only when the key is absent.

Why does my .env change not take effect with godotenv?

godotenv.Load() does not overwrite variables already present in the process environment, so if the key is exported in your shell, Docker ENV, or CI runner, the .env value is silently ignored. Run echo $KEY to confirm, or switch to godotenv.Overload() to make the file win.

How do I convert an environment variable to an int or bool in Go?

Every env var is a string, so parse it yourself: strconv.Atoi for ints, strconv.ParseBool for bools, time.ParseDuration for durations. Or bind the whole config to a struct with kelseyhightower/envconfig, which converts types automatically from struct tags.

Why does t.Setenv panic in a parallel test?

os.Setenv mutates process-global state through C's setenv, which is not thread-safe and can crash a concurrent getenv. testing.T.Setenv guards against this by panicking with "t.Setenv called after t.Parallel" if the test or any ancestor is parallel. Set env vars before t.Parallel(), or avoid parallel tests that touch the environment.

Should I use Viper or envconfig for Go configuration?

Use envconfig when your config comes purely from environment variables and you want a lightweight struct-tag mapping with required and default support. Use Viper when you also need config files (YAML/TOML/JSON), remote stores, or command-flag binding in one precedence chain.

Stay up to date

Get notified about new guides, tools, and cheatsheets.