Validating Config at Startup With Zod and Pydantic
The service boots, serves traffic for ten minutes, and then crashes on the first checkout with TypeError: Cannot read properties of undefined (reading 'split') because ALLOWED_CURRENCIES was never set; a Python worker treats RETRY_LIMIT="three" as truthy and loops forever; and when a developer fixes one missing variable, the next start reveals another, and then another. Configuration read ad hoc with process.env.X or os.environ.get("X") scattered through the code fails late, one variable at a time, with errors that do not mention the variable. This page validates all configuration once, at startup, against a typed schema, as part of environment variable validation.
The result is a single config object the rest of the code imports, and a startup that either succeeds with fully typed values or fails immediately listing every problem.
Diagnostic
Count how configuration is read today and what happens when a variable is missing:
#!/usr/bin/env bash
set -euo pipefail
echo "direct env reads: $(grep -rcE 'process\.env\.[A-Z_]+' src | awk -F: '{s+=$2} END {print s}') (node) $(grep -rcE 'os\.(environ|getenv)' worker 2>/dev/null | awk -F: '{s+=$2} END {print s+0}') (python)"
env -u ALLOWED_CURRENCIES -u PAYMENTS_WEBHOOK_SECRET node dist/server.js 2>&1 | head -3 &
sleep 3; curl -s -o /dev/null -w 'status after start: %{http_code}\n' http://localhost:3000/health || true
kill %1 2>/dev/null || true
Expected bad output:
direct env reads: 47 (node) 19 (python)
server listening on :3000
status after start: 200
The service starts and reports healthy with two required variables missing; the failures will come later, one at a time, in whichever code path reads them first.
Root cause
Environment variables are untyped strings or undefined. Code that reads them where they are used spreads configuration knowledge across dozens of files, converts types inconsistently ("false" is truthy in JavaScript, bool("0") is True in Python), and only fails when a particular path runs. Startup appears successful because nothing touched the missing value yet, so healthchecks pass and the failure surfaces under real traffic, with a stack trace pointing at a string method rather than at configuration. Fixing one variable reveals the next because nothing enumerates them all. A schema that describes every variable — type, format, default, whether it is required — validated in one place at startup, turns configuration into a single, checkable contract.
The local-development angle matters as much as the production one. A new hire whose .env is missing three keys should learn that in the first second of docker compose up, with the three names printed, not from three separate crashes spread over their first afternoon. Startup validation is effectively the most precise onboarding check a service can offer: it knows exactly what the service needs, because it is the service. Pairing it with the doctor script — which can call the same validation without starting the server — gives new developers one clear list of what to fix before anything else runs.
Scattered reads also make configuration impossible to review. Nobody can answer "which variables does this service need?" without grepping the codebase, and the answer changes silently with every pull request. A single schema file makes the full contract visible in one place and every change to it a reviewable diff.
Resolution
- Node: define a zod schema and export one parsed config object.
import { z } from 'zod';
const schema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
DATABASE_URL: z.string().url(),
ALLOWED_CURRENCIES: z.string().transform((s) => s.split(',').map((c) => c.trim().toUpperCase())).pipe(z.array(z.string().length(3)).min(1)),
PAYMENTS_WEBHOOK_SECRET: z.string().min(16),
FEATURE_NEW_CHECKOUT: z.enum(['true', 'false']).default('false').transform((v) => v === 'true'),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
const issues = parsed.error.issues.map((i) => ` - ${i.path.join('.')}: ${i.message}`).join('\n');
console.error(`Invalid configuration:\n${issues}`);
process.exit(1);
}
export const config = parsed.data;
Save as src/config.ts and import config everywhere instead of reading process.env. z.coerce.number() turns "3000" into 3000; the explicit 'true' | 'false' enum avoids truthy-string bugs.
- Python: use pydantic-settings the same way.
from pydantic import Field, PostgresDsn, ValidationError
from pydantic_settings import BaseSettings, SettingsConfigDict
import sys
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
database_url: PostgresDsn
retry_limit: int = Field(default=3, ge=0, le=10)
queue_name: str = "jobs"
feature_new_checkout: bool = False
try:
settings = Settings()
except ValidationError as err:
print("Invalid configuration:", file=sys.stderr)
for e in err.errors():
print(f" - {'.'.join(str(p) for p in e['loc']).upper()}: {e['msg']}", file=sys.stderr)
sys.exit(1)
retry_limit: int rejects "three"; bool accepts true/false/1/0/yes/no consistently.
Never print secret values in errors. Both examples print only variable names and messages. Zod's messages do not include values for
minorurlfailures, and pydantic'serrors()messages omit input values when you print onlylocandmsg.Replace direct reads with the config object and block new ones:
#!/usr/bin/env bash
set -euo pipefail
git grep -nE 'process\.env\.' -- 'src/**' ':!src/config.ts' && { echo "read config from src/config.ts instead of process.env"; exit 1; } || echo "all config goes through src/config.ts"
Expected output
$ env -u ALLOWED_CURRENCIES -u PAYMENTS_WEBHOOK_SECRET PORT=abc node dist/server.js
Invalid configuration:
- PORT: Expected number, received nan
- ALLOWED_CURRENCIES: Required
- PAYMENTS_WEBHOOK_SECRET: Required
$ echo $?
1
The service refuses to start and lists all three problems in one message; with valid configuration it starts with typed values, and no other module reads the environment directly.
Because the container exits with code 1 before listening, Compose's healthcheck never passes and docker compose up --wait fails with the service named — so a misconfigured local stack is caught at up time rather than on the first request. In production the same behaviour stops a deployment from rolling out a pod that would fail under traffic.
Prevention
Run the config check in CI with the CI environment: a step that imports the config module and exits proves every required variable is present in the pipeline too.
Generate
.env.examplefrom the schema so the example and the validation cannot disagree; see keeping .env.example files current.Keep the lint against direct reads so new code uses the config object.
Platform caveats
Serverless and edge runtimes: some platforms inject environment variables lazily or per request; validate at module load in the handler file so cold starts fail fast rather than individual requests.
Python with
.envfiles: pydantic-settings reads.envonly ifenv_fileis set; real environment variables take precedence over the file, which matches Compose behaviour.
TypeScript builds: frontends that inline
import.meta.envat build time need validation at build time too; run the schema in the build script so a missing public variable fails the build.
Rollback
Keep the schema but downgrade failures to warnings temporarily if a deploy is blocked on a variable that is optional in practice:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- src/config.ts
npm run build
Frequently Asked Questions
Why validate at startup instead of where the variable is used?
Startup validation reports every problem at once, before the service accepts traffic, with the variable's name in the message. Validation at use time fails one variable at a time, under load, with unrelated stack traces.
How do I handle booleans in environment variables?
Parse them explicitly. In zod, accept 'true' | 'false' and transform to a boolean; in pydantic, declare the field as bool. Never rely on the truthiness of the raw string.
Will validation errors leak secrets into logs?
Not if you print only variable names and messages, as in the examples. Avoid logging the raw environment or the full error object, which may include input values.
Can the same schema drive documentation?
Yes. Descriptions and defaults in the schema can generate .env.example and a configuration reference, so documentation stays in step with validation.