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:
- If no
.envis found after walking to the filesystem root,Loadreturns silently (no-op in production). - Existing env vars are never overwritten (platform-set values take precedence).
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
- Type safety. Parse once, use the typed value everywhere.
- Defaults inline. Easy to see what happens when env vars are missing.
- Fail-safe. Parse errors fall back to defaults instead of crashing.
- No
APP_ENV. The.envfile's presence (or absence) is the only signal. No environment mode variable to manage or get out of sync.