go / env

I use small helper functions to read environment variables with type safety and default values.

The pattern

package env

import (
	"os"
	"strconv"
	"strings"
	"time"
)

func String(key, defaultValue string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return defaultValue
}

func Bool(key string, defaultValue bool) bool {
	if v := os.Getenv(key); v != "" {
		b, err := strconv.ParseBool(v)
		if err != nil {
			return defaultValue
		}
		return b
	}
	return defaultValue
}

func Int(key string, defaultValue int) int {
	if v := os.Getenv(key); v != "" {
		i, err := strconv.Atoi(v)
		if err != nil {
			return defaultValue
		}
		return i
	}
	return defaultValue
}

func Duration(key string, defaultValue time.Duration) time.Duration {
	if v := os.Getenv(key); v != "" {
		d, err := time.ParseDuration(v)
		if err != nil {
			return defaultValue
		}
		return d
	}
	return defaultValue
}

func Slice(key string, defaultValue []string) []string {
	if v := os.Getenv(key); v != "" {
		return strings.Split(v, ",")
	}
	return defaultValue
}

.env loader

Pair the typed getters with a minimal .env file loader for local development:

func Load() {
	f, err := os.Open(".env")
	if err != nil {
		return
	}
	defer f.Close()

	s := bufio.NewScanner(f)
	for s.Scan() {
		line := strings.TrimSpace(s.Text())
		if line == "" || line[0] == '#' {
			continue
		}
		key, val, ok := strings.Cut(line, "=")
		if !ok {
			continue
		}
		key = strings.TrimSpace(key)
		val = strings.TrimSpace(val)

		if os.Getenv(key) == "" {
			os.Setenv(key, val)
		}
	}
}

Two properties make this work across environments without an APP_ENV variable:

In development, create a .env file with secrets and config. In production (Render, Fly, etc.), set env vars through the platform. The same binary works in both environments with no mode switch.

Usage

Call Load at the top of main, then use typed getters:

func main() {
	env.Load()

	port := env.String("PORT", "8080")
	debug := env.Bool("DEBUG", false)
	timeout := env.Duration("TIMEOUT", 30*time.Second)
	dbURL := env.Require("DATABASE_URL")

	// ...
}

Benefits

Variations

For required env vars with no default:

func Require(key string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	panic(fmt.Sprintf("missing required env var: %s", key))
}

If you prefer to fail on parse errors:

func MustInt(key string, defaultValue int) int {
	if v := os.Getenv(key); v != "" {
		i, err := strconv.Atoi(v)
		if err != nil {
			panic(fmt.Sprintf("%s: %v", key, err))
		}
		return i
	}
	return defaultValue
}

Comparison with flag-style

Some packages use a flag-like pattern with pointers and Parse():

var port = env.Int("PORT", 8080)

func main() {
	env.Parse() // must call before using *port
	fmt.Println(*port)
}

I prefer direct values over pointers. Simpler to use, one less thing to forget.

← All articles