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:
- If there is no
.envfile,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")
debug := env.Bool("DEBUG", false)
timeout := env.Duration("TIMEOUT", 30*time.Second)
dbURL := env.Require("DATABASE_URL")
// ...
}
Benefits
- Type safety. No
strconvscattered through application code. - 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.
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.