How to Read Environment Variables in Go

Use the `os.Getenv` function from the standard library to retrieve a specific environment variable, or `os.Environ` to get a slice of all variables as "KEY=VALUE" strings.

The handshake between code and environment

You deploy a Go service to production. It starts. It binds to a port. It accepts connections. Then it crashes. The database URL is empty. The service is running, but it has no identity. Environment variables are the handshake between your code and the deployment environment. If the handshake fails, the program runs with the wrong configuration, or worse, it runs with defaults that work in development but destroy data in production.

Environment variables are key-value pairs injected into a process by the operating system before the program starts. In Go, they are accessed via the os package. The defining characteristic is that every environment variable is a string. The OS does not know about integers, booleans, or structs. It only knows text. Your code receives text and must interpret it.

Think of environment variables like a set of sticky notes attached to the process's window. The OS slaps them there before the process wakes up. Your code reads the notes. If a note is missing, you don't know if the OS forgot it or if someone deliberately left a blank note. Go gives you two tools to read these notes, and choosing the right one prevents silent failures.

Reading a single variable

The simplest way to read an environment variable is os.Getenv. You pass the key, and it returns the value as a string. If the key does not exist, it returns an empty string.

Here's the basic pattern: read the value, check if it's empty, and apply a default.

package main

import (
	"fmt"
	"os"
)

func main() {
	// GetEnv returns the value or an empty string if the key is missing
	port := os.Getenv("PORT")
	
	// Check for empty string to apply a fallback default
	if port == "" {
		port = "8080"
	}

	fmt.Printf("Server listening on port %s\n", port)
}

This works for simple cases where an empty string is an acceptable signal for "use the default." It fails when you need to distinguish between a missing variable and one that is explicitly set to an empty string.

The empty string trap

os.Getenv has a blind spot. It returns an empty string for two different situations: the key is missing, or the key exists but its value is empty. In many systems, DEBUG="" means "debug is off." In others, it means "debug is on but empty." If you use GetEnv, you cannot tell the difference.

Use os.LookupEnv when you need to know if the key exists in the environment, regardless of its value. It returns two values: the string value and a boolean indicating existence.

package main

import (
	"fmt"
	"os"
)

func main() {
	// LookupEnv returns the value and a boolean for existence
	val, ok := os.LookupEnv("DEBUG")
	
	// ok is true if the key exists, even if the value is empty
	if ok {
		fmt.Printf("DEBUG is set to: %q\n", val)
	} else {
		fmt.Println("DEBUG is not set in the environment")
	}
}

The boolean ok is the source of truth. If ok is false, the key is absent. If ok is true, the key is present, and val holds the string, which might be empty. This distinction matters for critical flags where an empty value has a different meaning than a missing value.

LookupEnv tells the truth. GetEnv guesses.

Parsing strings into types

Environment variables are strings. You rarely want a string. You want a port number, a timeout duration, or a boolean flag. Go does not convert types automatically. You must parse the string and handle the error.

The strconv package provides functions like Atoi for integers and ParseBool for booleans. These functions return the parsed value and an error. Ignoring the error is a common mistake that leads to silent failures. If parsing fails, the function returns the zero value for the type. For an integer, that is zero. A port of zero is invalid, but the program might continue running with the wrong value.

Here's how to parse a configuration value safely.

package main

import (
	"fmt"
	"os"
	"strconv"
)

func main() {
	// Read the raw string from the environment
	portStr := os.Getenv("PORT")
	
	// Parse the string into an integer
	port, err := strconv.Atoi(portStr)
	
	// Handle parsing errors immediately
	if err != nil {
		fmt.Printf("Invalid PORT value %q: %v\n", portStr, err)
		port = 8080 // Fallback to default
	}

	fmt.Printf("Using port %d\n", port)
}

The Go community accepts verbose error handling because it makes the unhappy path visible. if err != nil is not boilerplate; it is a contract. If the environment variable is malformed, the code catches it and decides what to do.

strconv.ParseBool is strict. It accepts "1", "t", "T", "TRUE", "true", "True". It rejects "yes", "on", or "enabled". If your deployment scripts use "yes", the parser returns an error. Validate your inputs against the parser's expectations.

Validation and fail-fast

Reading environment variables is only the first step. You must validate them. A port number might parse successfully but be out of range. A database URL might be syntactically valid but point to a non-existent host.

The best practice is to validate configuration immediately after reading it. If the configuration is invalid, the program should exit with a clear error message. Do not limp along with bad config. Fail fast.

package main

import (
	"fmt"
	"os"
	"strconv"
)

// Config holds the application configuration
type Config struct {
	Port    int
	Debug   bool
	Timeout int
}

// LoadConfig reads and validates environment variables
func LoadConfig() Config {
	portStr := os.Getenv("PORT")
	port, err := strconv.Atoi(portStr)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Invalid PORT: %v\n", err)
		os.Exit(1)
	}
	
	if port < 1024 || port > 65535 {
		fmt.Fprintf(os.Stderr, "PORT must be between 1024 and 65535\n")
		os.Exit(1)
	}

	debugStr := os.Getenv("DEBUG")
	debug := false
	if debugStr == "true" {
		debug = true
	}

	return Config{
		Port:  port,
		Debug: debug,
	}
}

func main() {
	cfg := LoadConfig()
	fmt.Printf("Config loaded: %+v\n", cfg)
}

This pattern centralizes configuration logic. The main function stays clean. The LoadConfig function handles reading, parsing, and validation. If anything fails, the program exits before doing any work.

Fail fast on bad config. A crashing server is better than a silent data leak.

Testing with environment variables

Testing code that reads environment variables requires care. os.Getenv reads from the global environment of the process. If you set a variable in one test, it remains set for subsequent tests unless you clean it up. This causes flaky tests where the order of execution matters.

Use os.Setenv to set variables during tests. Always restore the original value or unset the variable after the test finishes. The t.Cleanup function in Go's testing package runs after the test completes, ensuring cleanup happens even if the test fails.

package main

import (
	"os"
	"testing"
)

func TestLoadConfig(t *testing.T) {
	// Save the original value to restore later
	originalPort := os.Getenv("PORT")
	
	// Set the test value
	os.Setenv("PORT", "3000")
	
	// Cleanup restores the original value after the test
	t.Cleanup(func() {
		if originalPort == "" {
			os.Unsetenv("PORT")
		} else {
			os.Setenv("PORT", originalPort)
		}
	})

	cfg := LoadConfig()
	if cfg.Port != 3000 {
		t.Errorf("Expected port 3000, got %d", cfg.Port)
	}
}

os.Setenv modifies the process environment globally. Tests that run in parallel can interfere with each other if they share environment variables. Use t.Parallel cautiously with environment variables, or isolate tests that depend on them.

Use _ to discard values you don't need. val, _ := os.LookupEnv("KEY") says you checked existence but don't care about the value. Use this sparingly. Discarding errors is dangerous. Discarding unused results is fine.

Pitfalls and compiler errors

Environment variables are simple, but they hide traps.

The most common trap is the empty string. Code that checks if port == "" assumes the variable is missing. If the deployment sets PORT="", the check passes, and the default applies. This might be correct, or it might mask a configuration error. Use LookupEnv if the distinction matters.

Another trap is parsing errors. strconv.Atoi returns an error if the string contains non-numeric characters. If you ignore the error, you get zero. Zero is a valid integer, but it might not be a valid port. The compiler does not warn you about ignored errors. The compiler enforces syntax, not logic.

Forget to import os and the compiler rejects the program with undefined: os. Forget to use an imported package and you get imported and not used. These are hard errors that stop compilation. Parsing errors are runtime errors that stop execution. Handle both.

Security is a concern. Environment variables are visible to other users on the same machine via /proc/<pid>/environ on Linux. Do not store sensitive secrets like database passwords in environment variables if you can avoid it. Use a secrets manager or a file with restricted permissions. If you must use environment variables, ensure the process runs with minimal privileges.

Env vars are strings. Parse them or regret it.

When to use what

Choosing the right approach depends on your needs. Use the parallel structure below to decide.

Use os.Getenv when you need a simple value and an empty string is an acceptable default. Use os.LookupEnv when you must distinguish between a missing variable and one set to an empty string. Use strconv functions when you need to convert the string value to a typed value like an integer or boolean. Use a configuration library when your application requires complex validation, nested structures, or multiple configuration sources. Use os.Setenv in tests to mock environment variables, and always clean up with t.Cleanup. Use plain sequential code for configuration loading: the simplest thing that works is usually the right thing.

Where to go next