How to Use .env Files in Go with godotenv

Load .env files in Go by installing godotenv, calling godotenv.Load(), and accessing variables with os.Getenv().

The configuration trap

You spend three days building a feature. Locally, your app connects to the database, sends emails, and hits the payment API without a hitch. You push to production, and the app crashes instantly. The logs show a connection refused error on localhost. You hardcoded the database URL in your source code. Or worse, you relied on an environment variable that exists on your laptop but vanished the moment the code left your machine.

Configuration drift is the silent killer of deployments. Environment variables solve this by separating configuration from code. The .env file is the local development tool that keeps your machine's variables in sync with your team's expectations. It ensures every developer starts with the same baseline without sharing secrets or cluttering the repository.

Environment variables and the .env file

An environment variable is a key-value pair stored by the operating system. Your process can read these values at runtime. The OS injects them when the process starts. A .env file is just a text file containing these key-value pairs. It lives in your project directory. It is ignored by version control.

godotenv is a library that reads this file and injects the values into the process's environment map before your code runs. This way, you write code that reads from os.Getenv, and during development, godotenv ensures those variables exist. In production, the deployment platform provides the variables directly, and you skip the file entirely.

The convention is to include a .env.example file in your repository. This file lists all required variables with placeholder values. It documents the configuration surface for new developers. The actual .env file stays local and never gets committed.

Minimal setup

Here's the smallest working setup. You load the file, then read variables like normal. The library handles parsing and injection.

package main

import (
	"fmt"
	"os"

	"github.com/joho/godotenv"
)

func main() {
	// Load reads .env and merges into os.Environ.
	// Ignores errors if the file is missing, which is safe for optional configs.
	_ = godotenv.Load()

	// os.Getenv returns the value or empty string if missing.
	// This is the standard way to access env vars in Go.
	dbURL := os.Getenv("DATABASE_URL")
	fmt.Println(dbURL)
}

The underscore discards the error from Load. This is intentional. If the .env file is missing, the program should still run, assuming the variables are provided by the shell or CI/CD pipeline. Trust gofmt to sort your imports and format the code. You don't need to argue about indentation.

How godotenv works

When godotenv.Load() runs, it opens the .env file in the current directory. It parses lines, skipping comments and empty rows. It splits each line on the first equals sign. It trims whitespace and handles quotes. Then it calls os.Setenv for every pair. This modifies the process's environment map.

Subsequent calls to os.Getenv see the new values. If the file doesn't exist, Load returns an error but does not panic. If you need to parse a file at a specific path, use godotenv.Read. This returns a map of key-value pairs without modifying the environment. This is useful for testing or multi-tenant setups where you need to isolate configurations.

If the file contains invalid syntax, godotenv returns an error like invalid syntax at line 5. You should handle this if you use Read instead of Load. The compiler rejects the program with undefined: godotenv if you forget to import the package. Forget to use one and you get imported and not used.

Realistic configuration loading

Real apps need validation. You load the file, read the values, check for missing required fields, and apply defaults for optional ones. Structs keep the configuration organized and type-safe.

package main

import (
	"fmt"
	"log"
	"os"

	"github.com/joho/godotenv"
)

// Config holds application settings loaded from environment variables.
type Config struct {
	Port      string
	Database  string
	SecretKey string
}

// LoadConfig reads environment variables and validates required fields.
// It returns a populated Config or an error if validation fails.
func LoadConfig() (Config, error) {
	// Load .env file. Ignore error to allow shell-provided vars in CI/CD.
	_ = godotenv.Load()

	cfg := Config{
		Port:      os.Getenv("PORT"),
		Database:  os.Getenv("DATABASE_URL"),
		SecretKey: os.Getenv("SECRET_KEY"),
	}

	// Validate required fields.
	// os.Getenv returns empty string for missing keys.
	if cfg.Database == "" {
		return cfg, fmt.Errorf("DATABASE_URL is required")
	}
	if cfg.SecretKey == "" {
		return cfg, fmt.Errorf("SECRET_KEY is required")
	}

	// Default values for optional fields.
	if cfg.Port == "" {
		cfg.Port = "8080"
	}

	return cfg, nil
}

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		log.Fatalf("config error: %v", err)
	}

	fmt.Printf("Starting on port %s with db %s\n", cfg.Port, cfg.Database)
}

The if err != nil pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. log.Fatalf stops the program immediately if configuration is invalid. This fails fast before the app enters an undefined state. Public names start with a capital letter. Config is exported so other packages can read the settings.

Testing in isolation

Tests run in isolation. You don't want tests to read your local .env file because that couples tests to your machine state. You also don't want tests to leak variables to other tests. Go 1.17 introduced t.Setenv. This sets a variable for the duration of the test and restores the original value automatically.

package main

import (
	"testing"
)

func TestLoadConfig(t *testing.T) {
	// t.Setenv sets the var for this test and restores it after.
	// This prevents test pollution and isolation issues.
	t.Setenv("DATABASE_URL", "postgres://test:5432/db")
	t.Setenv("SECRET_KEY", "test-secret")

	cfg, err := LoadConfig()
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	if cfg.Database != "postgres://test:5432/db" {
		t.Errorf("expected test db, got %s", cfg.Database)
	}
}

Tests should never depend on external files. Inject variables directly in the test function. This makes tests deterministic and fast. The worst goroutine bug is the one that never logs, but the worst test is the one that passes locally and fails in CI because of missing environment setup.

Distinguishing missing from empty

os.Getenv returns an empty string if the key is missing. This makes it impossible to distinguish between a missing variable and a variable set to an empty string. os.LookupEnv returns a second boolean indicating presence.

// LookupEnv distinguishes between missing and empty values.
// It returns the value and a boolean true if the key exists.
val, exists := os.LookupEnv("OPTIONAL_FLAG")
if !exists {
	// Key is missing entirely.
	// Apply default logic here.
} else if val == "" {
	// Key exists but is empty.
	// Handle empty string case.
}

Use os.LookupEnv when the distinction matters. For example, a flag that can be explicitly disabled by setting it to an empty string needs this check. Otherwise, os.Getenv is sufficient for most cases.

Pitfalls and security

Secrets belong in the vault, not the repo. Never commit the .env file. Add it to .gitignore. If you accidentally commit secrets, rotate them immediately. The .env file is a development convenience, not a production solution.

In production, use the platform's secrets manager. Kubernetes ConfigMaps and Secrets, Docker secrets, or cloud provider environment settings inject variables directly. The binary remains the same. The configuration comes from the environment. This follows the twelve-factor app methodology.

godotenv.Load merges variables. If the shell already has DATABASE_URL, Load keeps the shell value and ignores the file value. Use godotenv.Overload if you need the file to take absolute priority. This is rare but useful when debugging local overrides.

Docker supports .env files natively. You can skip godotenv in containerized apps by passing the file to the runtime.

# Docker can load .env files directly without godotenv.
# This keeps the binary clean and relies on the container runtime.
docker run --env-file .env my-app

This reduces dependencies and keeps the Go binary focused on application logic. The container runtime handles configuration injection.

Decision matrix

Use godotenv.Load when you want the .env file to fill in missing variables without overriding values set by the shell or CI/CD pipeline. Use godotenv.Overload when you need the .env file to take absolute priority and replace any existing environment variables. Use os.Getenv directly without godotenv in production deployments where the platform injects variables via Docker secrets, Kubernetes ConfigMaps, or cloud provider environment settings. Use a config file format like YAML or JSON when your configuration is too complex for flat key-value pairs, such as nested structures or arrays. Use godotenv.Read when you need to parse the file contents into a map without modifying the process environment, useful for testing or multi-tenant setups.

Configuration is plumbing. Run it through every long-lived call site. Keep secrets out of the code. Trust the environment.

Where to go next