Every Go application I've built eventually needs to read configuration from environment variables. Database URLs, API keys, port numbers, feature flags. Hardcoding these values is a recipe for disaster, and Go makes it straightforward to handle them properly.

The Basics: os Package

Go's standard library has everything you need. The os package gives you direct access to environment variables:

package main

import (
    "fmt"
    "os"
)

func main() {
    // Get a variable (returns empty string if not set)
    dbHost := os.Getenv("DB_HOST")
    fmt.Println("DB Host:", dbHost)

    // Check if a variable exists
    port, exists := os.LookupEnv("PORT")
    if !exists {
        port = "8080" // default
    }
    fmt.Println("Port:", port)
}

The difference between Getenv and LookupEnv matters. Getenv returns an empty string whether the variable is empty or doesn't exist. LookupEnv tells you the difference with its second return value.

Setting Variables

// Set for the current process
os.Setenv("APP_MODE", "production")

// Remove a variable
os.Unsetenv("APP_MODE")

// Get all environment variables
for _, env := range os.Environ() {
    fmt.Println(env)
}

Using .env Files with godotenv

In development, I don't want to export variables manually every time. The godotenv package loads variables from a .env file:

// .env file
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=secret
API_KEY=abc123
package main

import (
    "fmt"
    "log"
    "os"

    "github.com/joho/godotenv"
)

func main() {
    err := godotenv.Load()
    if err != nil {
        log.Println("No .env file found, using system env")
    }

    dbHost := os.Getenv("DB_HOST")
    fmt.Println("Connected to:", dbHost)
}

One important detail: godotenv.Load() does not override existing environment variables. If DB_HOST is already set in your system, the .env file value is ignored. This is the right behavior for production where you want system-level config to take priority.

A Config Pattern I Use

For anything beyond a small script, I create a config struct that loads everything at startup:

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

func LoadConfig() Config {
    return Config{
        DBHost:     getEnv("DB_HOST", "localhost"),
        DBPort:     getEnv("DB_PORT", "5432"),
        DBUser:     getEnv("DB_USER", "postgres"),
        DBPassword: getEnv("DB_PASSWORD", ""),
        Port:       getEnv("PORT", "8080"),
        AppMode:    getEnv("APP_MODE", "development"),
    }
}

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

This gives you type safety, defaults in one place, and a single point where all config is loaded. I use this pattern in every Go project now.

Security Notes

  • Never commit .env files. Add .env to your .gitignore immediately.
  • Use different .env files per environment. I keep .env.example in the repo with placeholder values so new developers know what to set up.
  • In production, use your platform's secrets manager. AWS Secrets Manager, GCP Secret Manager, or even Docker/Kubernetes secrets. Don't rely on .env files in production.

For more on securing your applications, I wrote about security basics every developer should know. And if you're building APIs in Go, check out my posts on session management with Redis and PostgreSQL transactions.