Configuration is where tidy software meets messy reality. Pick a format for the people editing it, define one boring precedence order, validate at startup, and refuse to turn YAML into a programming language.
🎙️ Published & recorded:
My default is blunt: JSON for machine-owned data, TOML for human-owned application settings, dot env for a small deployment boundary, and YAML only when the surrounding tool already demands it. Formats are not interchangeable decoration. They make different mistakes easy.
# Use this decision table before bikeshedding syntax
machine writes and reads it → JSON
humans maintain app settings → TOML
platform injects 5–20 strings → .env
tool ecosystem requires YAML → YAML
need conditions, loops, imports → use code, not config
JSON has one great virtue: nearly every language agrees on what it means. It has no comments, keys require double quotes, and the final item cannot carry a comma. I would not ask operators to maintain a huge JSON file, but I trust it as a wire format and generated artifact.
{
"host": "127.0.0.1",
"port": 8080,
"features": ["search", "billing"]
}
json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes. Node commonly reports SyntaxError: Unexpected token } in JSON at position 42. Fix it step by step: open the reported line or position; inspect the item immediately before the closing brace or bracket; remove its trailing comma; then run a parser, not a formatter, to confirm the file loads.
YAML looks calm because punctuation disappears. The cost is invisible structure. Indentation is data, tabs are forbidden by most parsers, and plain words may become booleans, dates, or numbers depending on the parser version. Quote anything that must stay a string.
service:
host: "127.0.0.1"
port: 8080
mode: "on" # quoted: must remain the string "on"
release: "2026-07-24" # quoted: not a date object
code: "0017" # quoted: preserve leading zeroes
yaml.scanner.ScannerError: mapping values are not allowed here; other parsers say found character '\t' that cannot start any token. First enable rendered whitespace in your editor. Replace tabs with spaces. Then align sibling keys to the same column and parse the file again. Do not “fix” it by adding random spaces until the error moves.
I oppose using YAML as a programming language. Anchors for a repeated block are tolerable. Templates, custom tags, conditionals, string interpolation, and five-level merges are code wearing a worse debugger. If configuration needs control flow, write a small typed program that emits plain config and test that program.
TOML is my choice when humans own the file. Strings look like strings, sections are explicit, and comments survive. Dot env is not a richer rival. It is a convenient list of strings for process environments. Keep it flat and small.
# config.toml
[server]
host = "127.0.0.1"
port = 8080
[database]
pool_size = 10
ssl = true
# .env — every value enters the process as text
APP_PORT=8080
APP_DEBUG=false
DATABASE_URL=postgresql://localhost/app
APP_DEBUG=false enabling debug mode. Environment variables are strings, and in many languages any non-empty string is truthy. Read the exact value, normalize case, accept only true or false, and reject everything else. Never coerce it with a generic truthiness check.
Configuration bugs often come from a valid value winning from an unexpected source. Pick an order, publish it beside the settings, and expose the resolved value plus its source. Mine is: command-line flag, then environment, then local file, then committed file, then built-in default.
# highest priority wins
1. --port 9000 # explicit for this invocation
2. APP_PORT=9000 # deployment override
3. config.local.toml # uncommitted machine override
4. config.toml # shared project choice
5. default: 8080 # application fallback
# useful startup output
config: server.port=9000 (source: APP_PORT)
A config parser only proves the punctuation is legal. A schema proves the values are usable. Validate types, ranges, required fields, unknown keys, and relationships before the service accepts traffic. Failing at startup is kinder than discovering an invalid timeout during a customer request.
# schema-shaped rules, independent of library
server.port required integer, 1..65535
server.host required non-empty string
log.level one of: debug, info, warning, error
request_timeout number greater than 0
production forbids debug = true
unknown keys rejected
ConfigurationError: server.port must be between 1 and 65535; got 70000
Additional properties are not allowed ('timout' was unexpected). Fix it step by step: compare the key with the schema; rename timout to timeout; rerun validation; then add the invalid spelling to a test fixture. Silently ignoring unknown keys turns a typo into a production mystery.
Keep schema errors concrete. Name the full path, the expected constraint, and the received value. “Invalid configuration” saves the programmer five seconds and costs the operator half an hour.
A repository config file may say which database to use. It must not contain the password. Put secret values in a deployment secret store or protected environment, commit an example file with fake placeholders, and let configuration hold the reference. Encryption inside the same repository is not protection if the decryption key lives beside it.
# config.toml — safe to commit
[database]
url_env = "DATABASE_URL"
# .env.example — names and harmless samples only
DATABASE_URL=postgresql://user:password@localhost/app
SESSION_SECRET=replace-with-a-random-value
# .gitignore
.env
.env.*
!.env.example
Push cannot contain secrets. Do not merely delete the line and retry. First revoke or rotate the credential, because commit history still contains it. Then remove it from the current tree and relevant history, replace it with an environment lookup, add the file pattern to .gitignore, and run the secret scan again.
If a secret touched a commit, assume it escaped. Rotation is the fix; history rewriting is cleanup. Reverse those priorities and you may produce a tidy repository around a live stolen credential.
Configuration spends more time being reviewed than being written. Stable key order, one item per line, comments that explain reasons, and no generated churn make mistakes visible. A format that saves three lines but hides one changed permission is a bad bargain.
# reviewable: one semantic change produces one obvious diff
allowed_origins = [
"https://admin.example.com",
+ "https://reports.example.com",
]
# noisy: generated timestamp changes on every run
-generated_at = "2026-07-23T18:02:11Z"
+generated_at = "2026-07-24T09:41:53Z"
This is another reason not to make YAML programmable. A reviewer should see the deployed value in the changed file, not mentally execute anchors, templates, environment substitutions, and conditionals spread across four directories.
When configuration fails, stop staring at the file you meant to load. Ask the running process what path it opened, what bytes it parsed, and which source won. Relative paths, working directories, text encoding, and stale processes cause more trouble than exotic parser bugs.
# debug in this order
1. print the absolute config path
2. confirm that file exists for the running user
3. print a checksum, never secret contents
4. parse it with the same library and version as production
5. validate the schema
6. print resolved keys with source names; redact values
7. confirm whether reload or restart is required
FileNotFoundError: [Errno 2] No such file or directory: 'config.toml'. Print the process working directory and the resolved absolute path. Pass an absolute path from the service definition or resolve relative to the executable, not whichever directory happened to launch it. Then verify file permissions as the service account.
Unexpected token '', "{..." is not valid JSON. The invisible character is a byte-order mark. Inspect the file as bytes, save it as UTF-8 without BOM, and parse again. If files arrive from outside your control, strip a leading BOM explicitly at the ingestion boundary and test that case.
Keep this order of operations. It prevents most config incidents and makes the remaining ones short.
# choose
JSON machine-owned, universal, strict
TOML human-owned application settings
.env small set of deployment strings
YAML only when the tool ecosystem requires it
code conditions, loops, imports, computation
# parse safely
JSON double quotes; no comments; no trailing comma
YAML spaces, never tabs; quote ambiguous strings
TOML keep sections shallow and names explicit
.env parse types yourself; every input starts as text
# precedence, highest first
CLI → environment → local file → committed file → default
# startup contract
parse → reject unknown keys → validate types/ranges → report source
# secrets
secret manager / protected env ✓
.env.example with fake values ✓
real credential in Git ✗
leaked credential → rotate first, clean history second
# debugging
absolute path → permissions → checksum → parser → schema → source
changed file, unchanged app → inspect precedence and reload behavior
The best configuration system is deliberately boring. One format, one precedence rule, one schema, useful startup errors, and no secret values in the repository. When someone asks to add logic to YAML, say no and move that logic into tested code.