go / env

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

The pattern

package env

import (
	"fmt"
	"os"
	"time"
)

// Require returns the value of key, or panics if it is unset or
// empty. Use for config the process can't start without.
func Require(key string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	panic(fmt.Sprintf("missing required env var: %s", key))
}

// String returns the value of key, or defaultValue if unset/empty.
func String(key, defaultValue string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return defaultValue
}

// Duration parses the value of key with time.ParseDuration.
// Unset, empty, or unparseable values fall back to 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
}

.env loader

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

// Load walks up from the current working directory looking for a
// .env file and sets any variables not already present in the
// environment. Safe to call unconditionally: silent no-op when
// no .env is found.
func Load() {
	dir, err := os.Getwd()
	if err != nil {
		return
	}
	for {
		path := filepath.Join(dir, ".env")
		if _, err := os.Stat(path); err == nil {
			loadFile(path)
			return
		}
		parent := filepath.Dir(dir)
		if parent == dir {
			return // reached filesystem root
		}
		dir = parent
	}
}

// loadFile parses a .env-style file: one KEY=VALUE per line,
// # comments ignored, single- or multi-line quoted values
// supported. Existing env vars are never overwritten.
func loadFile(path string) {
	f, err := os.Open(path)
	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 len(val) >= 1 && (val[0] == '"' || val[0] == '\'') {
			quote := val[0]
			if len(val) >= 2 && val[len(val)-1] == quote {
				// single-line quoted
				val = val[1 : len(val)-1]
			} else {
				// multi-line: accumulate until closing quote
				var sb strings.Builder
				sb.WriteString(val[1:])
				for s.Scan() {
					next := s.Text()
					sb.WriteByte('\n')
					if len(next) > 0 && next[len(next)-1] == quote {
						sb.WriteString(next[:len(next)-1])
						break
					}
					sb.WriteString(next)
				}
				val = sb.String()
			}
		}
		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")
	timeout := env.Duration("TIMEOUT", 30*time.Second)
	dbURL := env.Require("DATABASE_URL")

	// ...
}

Benefits

← All articles