Config Files in 10 Minutes

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:

01Choose for the editor, not fashion

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
A useful constraint
One service should have one primary config format. Supporting JSON, YAML, and TOML simultaneously sounds friendly until values parse differently and every bug report begins with “which loader did you use?” Convert at the boundary instead.

02JSON: strict is the feature

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"]
}
Exact failure: trailing comma
Python reports 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.

03YAML: readable until it guesses

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
Exact failure: indentation
PyYAML can report 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.

04TOML for files, .env for injection

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
The false that becomes true
A real symptom is 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.

05Precedence must fit in one sentence

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)
Real symptom: “I changed it, nothing happened”
Fix it in order: print the final value; print which source supplied it; inspect the process environment, including service-manager settings; inspect command-line arguments; restart only if the app does not reload config. Editing the file again is pointless until you know whether the file is losing precedence.

06Validate once, at startup

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
Exact failure: unknown setting
A strong validator says 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.

07Secrets are references, not settings

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
Exact failure: secret scanning
GitHub push protection may say 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.

08Optimize for the next diff

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"
House rules worth enforcing
Sort unordered maps. Preserve deliberate list order. Keep generated files separate from hand-edited files. Format in continuous integration and check the result rather than rewriting during deployment. Never use YAML merge acrobatics to reduce repetition: explicit repetition is often the more honest diff.

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.

09Troubleshoot the loaded bytes

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
Exact failure: wrong path
Python says 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.
Exact failure: invisible encoding
A JSON loader may report 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.

10Config files cheat sheet

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.