Skip to content

Read environment variables with defaults snippet

Configuration belongs in the environment — but getenv returns a string or nothing, and every consumer downstream wants typed values.

Configuration belongs in the environment — but getenv returns a string or nothing, and every consumer downstream wants typed values. Read, parse, and DEFAULT at one boundary at startup, then pass real values around; a program that re-reads os.environ deep in a loop cannot be tested and one that treats the string "false" as falsy inverts its flags. Missing and empty are different answers, and secrets read this way must never be logged.

Runnable recipe · 14 languages
System & CLIenvconfigenvironment-variablescli12-factor

Every language

14 implementations, copy-ready. One at a time with syntax highlighting, or all inline.

JSJavaScript
// parse at the boundary, pass real values around after:
const port = Number(process.env.PORT ?? '8000');
const host = process.env.HOST ?? '127.0.0.1';

// dev only — loads .env into process.env before these reads:
// require('dotenv').config();

'' (empty string) is NOT missing — ?? keeps it, and Number('') is 0. If empty should fall back too, filter it explicitly. dotenv is a dev-time loader, never a production dependency.

TSTypeScript
function readInt(name: string, fallback: number): number {
  const raw = process.env[name];
  if (raw === undefined || raw === '') return fallback; // missing AND empty
  const n = Number.parseInt(raw, 10);
  return Number.isNaN(n) ? fallback : n;
}

const port = readInt('PORT', 8080);

Needs @types/node (or `declare const process`) for process.env to typecheck. parseInt never throws — it returns NaN, so the Number.isNaN check IS the error path.

GoGo
import (
	"fmt"
	"os"
	"strconv"
)

// read once at startup — everything downstream takes an int:
func mustPort() int {
	raw, ok := os.LookupEnv("PORT") // (value, set?) — the three-way answer
	if !ok {
		return 8080
	}
	n, err := strconv.Atoi(raw) // 'abc' and '' both land here
	if err != nil {
		panic(fmt.Sprintf("bad PORT %q", raw))
	}
	return n
}

os.Getenv flattens missing and empty into '' — LookupEnv's bool return splits them. Atoi is the typed boundary; never echo the value in errors when the var is a secret.

RsRust
use std::env;

fn read_port() -> u16 {
    env::var("PORT")
        .ok()
        .and_then(|v| v.parse::<u16>().ok())
        .unwrap_or(8080)
}

// strict: bubble both failures to the caller instead of a default
fn require_port() -> Result<u16, Box<dyn std::error::Error>> {
    Ok(env::var("PORT")?.parse::<u16>()?)
}

env::var fails NOT_PRESENT when unset and NotUnicode for non-UTF-8 values — the ? chain converts both. parse::<u16>() is the typed boundary; unwrap_or is the default.

PHPPHP
// trap: ?: fires on EVERY falsy string — '' and '0' hit the default too:
$host = getenv('HOST') ?: '127.0.0.1';

// getenv returns false (not null) when missing — test exactly that:
$raw = getenv('PORT');
$port = ($raw === false || $raw === '') ? 8000 : (int) $raw;

// ?? is the null-safe form for the superglobals, not for getenv:
$host = $_SERVER['HOST'] ?? '127.0.0.1';

?: conflates '0' and '' with missing — the falsy-string trap. ?? never fires for getenv's false return; it is the safe form on $_ENV/$_SERVER, where a missing key is null.

PyPython
import os

port = int(os.environ.get('PORT', '8080'))  # typed at the boundary
host = os.environ['HOST']                   # strict: KeyError names the var
debug = os.environ.get('DEBUG') == '1'      # explicit compare, never bool()

os.getenv('K', d) is the same get-with-default over os.environ. The bool trap: bool('false') is True — every non-empty string is truthy — so compare against the exact allowed spellings.

CC
#include <stdlib.h>

// getenv returns char* or NULL — parse at the boundary, pass longs around:
long read_port(void) {
    const char *raw = getenv("PORT");
    if (raw == NULL || *raw == '\0') return 8080; // missing AND empty
    char *end;
    long n = strtol(raw, &end, 10);
    return *end == '\0' ? n : 8080; // trailing junk = parse failure
}

The string is owned by the environment — never free() it, and a later setenv can invalidate the pointer. Missing vs empty are NULL vs "" — two different answers. strtol signals by end-pointer, not by return: *end == '\0' is the whole check.

C++C++
#include <cstdlib>
#include <stdexcept>
#include <string>

int read_port() {
    const char *raw = std::getenv("PORT");
    if (raw == nullptr || *raw == '\0') return 8080;
    try {
        return std::stoi(raw); // the two throws live here
    } catch (const std::invalid_argument &) { // "abc", ""
        return 8080;
    } catch (const std::out_of_range &) {      // huge values
        return 8080;
    }
}

std::stoi throws std::invalid_argument on garbage and std::out_of_range on overflow — and stoi("") throws rather than returning 0, unlike C's strtol. std::getenv has the same environ ownership as C: the pointer is not yours to free, and it is not std::string.

C#C#
int port = int.TryParse(
    Environment.GetEnvironmentVariable("PORT"),
    out var parsed) ? parsed : 8080;

string? host = Environment.GetEnvironmentVariable("HOST"); // null when missing

GetEnvironmentVariable returns null when unset — TryParse(null) is false, so the default falls out with no null branch. Real apps read IConfiguration (appsettings.json + env overlay), not raw env access.

JvJava
int port = Integer.parseInt(
        System.getenv().getOrDefault("PORT", "8080"));

// required — a named message beats NumberFormatException on null:
String host = System.getenv("HOST");
if (host == null) {
    throw new IllegalStateException("HOST is not set");
}

System.getenv is a snapshot taken at JVM start — setenv calls afterwards are invisible to it, so read once in main. Keep env values out of exception messages and logs when they are secrets.

SwSwift
import Foundation

let env = ProcessInfo.processInfo.environment // [String: String]
let port = Int(env["PORT"] ?? "") ?? 8000
let host = env["HOST"] ?? "127.0.0.1"

environment is a plain [String: String] — missing keys are nil, values stay strings. The double-?? chains default + parse; same API on macOS and Linux command-line tools.

KtKotlin
fun requireEnv(name: String): String =
    System.getenv(name) ?: error("missing env var: $name")

fun readEnv(name: String, default: String): String =
    System.getenv(name) ?: default

val port = readEnv("PORT", "8080").toInt()
val host = requireEnv("HOST")

Same JVM snapshot rule as Java. The elvis operator splits optional from required in one line each; error() names the missing var at startup instead of an NPE three frames later.

RbRuby
port  = ENV['PORT'] || '8080'   # nil (missing) falls back; '' does not
host  = ENV.fetch('HOST')       # required — KeyError when absent
debug = ENV['DEBUG'] == '1'     # values are strings; there is no to_bool

ENV is Hash-like with STRING keys AND string values only — parse once (Integer(port, 10)) and pass the number around. ENV.fetch('K', default) is the explicit-default spelling of ||.

ZigZig
const std = @import("std");

fn readPort(gpa: std.mem.Allocator) u16 {
    const raw = std.process.getEnvVarOwned(gpa, "PORT")
        catch return 8000; // error.EnvironmentVariableNotFound when unset
    defer gpa.free(raw);   // the caller owns the returned string
    return std.fmt.parseInt(u16, raw, 10) catch 8000;
}

getEnvVarOwned takes an allocator and errors when unset — you OWN the returned buffer, so defer free it. Read once into a config struct at startup; threading the allocator deep into call sites is the re-read anti-pattern.