Choosing YAML TOML JSON or INI for Config Without Regret
Pick a config format by who edits it and what reads it. One config tested in YAML, TOML, JSON, INI and more: types, traps, errors, size and switching cost.

Every project picks a configuration format, and most pick it by habit: whatever the last project used, whatever the framework suggests, whatever the first tutorial showed. The choice is cheap to make and expensive to reverse, because the format leaks into every tool, script, review and mistake that follows. This guide gives a way to choose that does not rest on taste. It starts from who edits the file and what reads it, tests the formats on the same data and records what happened, and ends with a decision procedure and the cases where the framework should not apply. The detailed comparisons are in the linked posts; this is the synthesis. For the language itself, begin with YAML syntax you need to stop guessing.
The two questions
Two questions settle most cases before any feature comparison.
Who edits the file? A person in a text editor, a program, or both. A hand-edited file needs comments, readable layout and forgiving syntax. A program-written file needs one unambiguous reading. A file edited by both is the hard case, because a program rewrite destroys the person's comments and layout.
What reads the file? Your own code, or other people's tools. If a platform reads it (Kubernetes, a CI system, Docker Compose, an OpenAPI toolchain), the platform chose the format and you follow. If only your code reads it, you can choose on merit.
The answers give four quadrants:
| Your code reads it | A platform reads it | |
|---|---|---|
| A person edits | Choose: TOML, YAML, JSON5, INI | Use the platform's format, with guardrails |
| A program writes | JSON (or a binary format) | The platform's format, generated |
Most of the interesting choices are in the top left, and that is where this guide spends its effort. The other three cells are decided for you, and the work there is making the generated or platform-mandated file safe.
One config, eight treatments
To compare on equal footing, we wrote the same configuration in several formats: a name, a version 3.10, a boolean, a port, a list of tags, a nested database section with a pool, and a list of two environments with replica counts. Byte sizes:
YAML 231 bytes 15 lines
TOML 253 bytes 22 lines
JSON indented 361 bytes 28 lines
JSON minified 222 bytes 1 lines
INI 202 bytes 17 lines
env vars 218 bytes 11 lines(The YAML and TOML versions loaded to identical Python data, which we checked.) The differences are small and uninteresting. Compact JSON, YAML, TOML, INI and environment variables all land within 20 percent of each other, and indented JSON is the outlier. Size is not a reason to pick one. The differences that matter are in what each does with the text.
What each format does with awkward values
The test document had four values chosen to expose differences: version: 3.10, debug: no, zip: 02134 and a date. Here is what each family did.
| Format | 3.10 | no | 02134 | Date | Duplicate key |
|---|---|---|---|---|---|
| YAML (PyYAML, 1.1 rules) | Float 3.1 | False | Integer 1116 (octal) | Date object | Last wins, silent |
| YAML (1.2 libraries) | Float 3.1 | String no | Integer 2134 or string | String or object | Error in some |
| TOML | String if quoted | Syntax error | Syntax error | Native date | Error |
| JSON | Number 3.1 if bare | Invalid | Invalid (leading zero) | Not a type | Last wins |
| JSON5 | Number 3.1 if bare | Syntax error | Syntax error | Not a type | Not tested |
| HJSON | Quoteless string 3.10 | String no | String 02134 | String | Not tested |
INI (configparser) | String 3.10 | String no | String 02134 | String | DuplicateOptionError |
| Environment variable | String | String | String | String | Not applicable |
| StrictYAML | String 3.10 | String no | String 02134 | String | DuplicateKeysDisallowed |
The pattern is the main finding. Formats that infer types from bare words (YAML) give surprising answers, and the answer depends on the library version. Formats that make types explicit in the syntax (TOML, JSON, JSON5) refuse the ambiguous input with an error. Formats with no types (INI, environment variables, StrictYAML, HJSON quoteless) hand you the text and leave interpretation to you. The three strategies fail differently: inference fails silently with a wrong value, explicit typing fails loudly at load, and no typing fails later when you use a string as a number. Loud failure is the best of the three for configuration, which is the argument for TOML and for typed decoding.
The detailed runs are in YAML versus TOML for application config, INI and properties files versus YAML, JSON5 and HJSON as friendlier options and StrictYAML and safer subsets of YAML.
What each format cannot say
Every format has a hole, and the question is whether yours matters.
JSON has no comments. A configuration without comments loses the reason for each value. JSONC and JSON5 patch this and are not universally read.
TOML has no null, so "unset" is a missing key or a sentinel. Lists of records need [[table]] blocks, which scale poorly past a dozen entries, and deep nesting needs long dotted headers.
INI has no nesting beyond one level, no lists and no types. Dialects differ on comments, case and quoting, and configparser lowercases keys by default and chokes on a percent sign.
YAML can say nearly anything, which is its problem: anchors, tags, several documents, multiple ways to write a list, and type inference that varies by version.
Environment variables are flat strings with no structure and a leak risk in child processes.
HJSON and JSON5 are not read by platforms and have smaller ecosystems. HJSON's quoteless strings run to the end of the line, so [web, public] on one line failed to parse.
CUE, Jsonnet and Dhall are programs, not data: they add a toolchain and move the source of truth.
Speed and size at scale
Configuration is small, so speed rarely matters. When data grows into the thousands of records it starts to. We parsed 20,000 records in Python:
json.loads 29 ms | yaml CSafeLoader 1608 ms | yaml safe_load 5688 msJSON parsed in 29 milliseconds, PyYAML with libyaml in 1.6 seconds and pure Python PyYAML in 5.7 seconds. Across languages the YAML libraries ranged from 108 ms to 1.5 s for the same data. The rule is simple: if a program writes or reads it in bulk, use JSON (or JSON Lines, or a columnar format). YAML is for files people open. The measurements are in YAML versus JSON for humans and machines and large YAML files and parsing speed.
Safety and error quality
Formats differ in how they fail and in what they expose. TOML, JSON and INI have no tag mechanism, so no object-construction vulnerability; YAML does, in some libraries, and the safe-loader rules apply (YAML security pitfalls). YAML and HJSON allow reuse that can be abused (aliases), which JSON, TOML and INI lack. Error messages are good everywhere that matters: TOML reported Cannot overwrite a value (at line 2, column 6), configparser named the duplicate option and line, StrictYAML named the disallowed construct, and CUE named the path and the two source lines in conflict. YAML parsers vary, from libyaml-style did not find expected ',' or ']' to PHP's terse Malformed inline YAML string.
Duplicate keys are the quiet exception. TOML, INI (by default), StrictYAML and several YAML libraries reject them; Python's json, PyYAML, Psych, SnakeYAML and YamlDotNet keep the last silently. A format or library that rejects duplicates is safer for hand-edited files.
Configuration as code
When the configuration is generated from many similar pieces, a configuration language beats any data format. We generated 200 services from 5 to 10 lines of CUE, Jsonnet or Dhall. CUE and Jsonnet finished in about a quarter of a second including container start; Dhall's 47 seconds included fetching its Prelude over the network on emulated hardware, so we do not compare it. Each rejected a bad port or environment before writing output: CUE with invalid value 90000 (out of bound <=65535), Jsonnet with port out of range for worker, Dhall with a type error. These tools pay off when repetition is the problem and cost a toolchain and a learning curve when it is not. See Dhall, CUE and Jsonnet for config at scale.
Where environment variables fit
Environment variables are not a replacement for a file but a layer above it. They carry what varies by deployment, secrets and the path to the file. They are strings, and the failures are predictable: APP_DEBUG=false was truthy until coerced, APP_DB__POOL=20 broke arithmetic and APP_TAGS=a,b was one string. Layering defaults, then the file, then coerced environment values gave the right types, and a typed settings class does the coercion in one place. The details are in environment variables versus config files.
Scenarios
The framework in practice, for common situations.
A command-line tool's user configuration. Hand-edited by users, read only by your tool. TOML if flat or shallow, YAML if users will write lists of rules. Provide a documented example file and a command that validates it.
A web application's settings. Defaults in a file (TOML or YAML), per-deployment values and secrets in environment variables, typed settings class at startup. Avoid INI unless the framework mandates it.
Kubernetes, Compose, CI and OpenAPI. YAML, mandated. Make it safe with schemas, linters and effective-configuration review (validating YAML before it breaks a deploy, YAML for infrastructure files developers edit weekly).
A data pipeline definition. A list of steps with dependencies: YAML reads well, and a small validator should check the graph before running (declaring pipelines in YAML for data jobs).
Feature flags edited by operators. If changes must be immediate, a flag service. If they ship with code, a small file with strict schema.
Test data and fixtures. YAML for readability, loaded inside a transaction (fixtures in YAML for database tests).
Machine-to-machine payloads and caches. JSON, or JSON Lines for streams.
Static site content metadata. YAML front matter, because the ecosystem expects it (YAML front matter and content data).
Hundreds of near-identical service definitions. A configuration language, or a generator script, with the output validated.
Writing a config file that survives its readers
Whatever the format, the habits that separate a durable configuration file from a fragile one are the same, and most of them cost nothing.
Ship an example. A complete, commented example file in the repository documents more than a page of prose, and a test that loads it keeps it honest. Users copy the example, so make it safe: placeholder secrets, safe defaults and comments that explain the units and the allowed values.
Put units in names or comments. timeout_seconds: 30 cannot be misread as milliseconds. A bare timeout: 30 is a bug waiting for the second person to edit it.
Fail on unknown keys. A misspelled key that falls back to a default is the commonest silent configuration bug. Every format discussed here can be loaded so that unknown keys are errors, with a typed model, a schema with additionalProperties: false or a strict mode, and every one of them is worth the line it costs.
Reject duplicates. Where the parser lets a repeated key through, add a lint rule. Two values for one setting is either a mistake or an attack.
Validate at startup, not at first use. Load the whole configuration into a typed object when the process starts, and refuse to run if it is wrong. A program that discovers a bad setting an hour later, on a rarely used path, fails in production.
Print the effective configuration. A command or a log line that shows the merged values and where each came from ends most investigations. Redact secrets by field name.
Keep secrets out. In every format, the file names where the secret lives and does not contain it (keeping secrets out of YAML config files).
None of these depends on the format, which is the point. A disciplined TOML file and a disciplined YAML file are both better than an undisciplined version of either, and the argument over formats usually matters less than whether the file has an example, a schema and a startup check.
How teams get this wrong
Several patterns recur, and each one is visible in the tests above.
Choosing by fashion. A format chosen because a popular tool uses it, for a file with a different job, brings that tool's trade-offs without its reasons. A Kubernetes-style YAML for a three-setting command-line tool buys type traps and indentation errors for nothing.
Mixing formats in one repository without a reason. A project with INI for one tool, YAML for another and JSON for a third has three parsers, three sets of traps and three kinds of errors. Sometimes platforms force it. When they do not, standardize.
Letting YAML grow logic. Files that gain templating, conditionals and loops become programs without tests or a debugger. The fix is to move the logic into code, or to a configuration language built for it, and leave the file as data.
Treating a string-typed format as typed. INI and environment variables carry text. The programs that read if config.debug against the string false shipped the bug our test reproduced. Convert once, at the boundary.
Trusting the editor. Squiggles in an editor are not enforcement. Put the schema in CI.
Ignoring the second reader. A file written for one parser and later read by another meets the other's rules. Our cross-language test gave three different meanings to one file. Quote what is text and test with every reader.
Never testing the failure path. The test that proves a misspelled key fails takes five lines, and its absence is why typos reach production.
Tooling you can lean on
Format choice interacts with tooling more than it appears to. JSON Schema validates the loaded data of YAML, TOML, JSON and JSON5 equally, because it works on the data model, so one schema covers a file whichever format carries it. Editors support JSON and YAML schemas through the language server, and SchemaStore's catalog lists well-known file names. The pre-commit-hooks collection includes syntax checks for YAML, TOML and JSON, and check-jsonschema hooks add schema validation, as shown in building a pre-commit check for YAML files. Formatters keep diffs clean: the browser-based YAML formatter and JSON formatter normalize layout, and every ecosystem has CLI equivalents.
Where tooling is thin, the format costs more. HJSON and JSON5 have fewer editor integrations. StrictYAML has Python tooling only. INI has no schema standard. Weigh the tooling you will rely on against the syntax you prefer: a slightly less pleasant format with a validator in every editor beats a pleasant one with none.
An example of the reasoning
Take a hypothetical team building a data-pipeline tool with a user-facing configuration: a name, a schedule, a list of steps with dependencies, and connection settings. Who edits it? Data engineers, by hand, in version control. What reads it? Their own tool. Both are in the top-left quadrant, so they can choose on merit.
They need lists of steps, which rules out INI. The steps have dependencies, so a list of records with a needs field fits YAML naturally and TOML awkwardly ([[steps]] repeated, with needs arrays). Engineers will write retries: 3 and schedule: "0 2 * * *", and the cron expression must be quoted in either format because of its asterisks. They want comments. They do not want type surprises, but the tool's own loader can fix that: it will parse with a YAML 1.2 loader, decode into typed models and validate the graph before running.
So they choose YAML, for the lists of records and the comments, and they pair it with the guardrails from this guide: a typed model with unknown keys rejected, a duplicate-key lint, a schema in the editor and a test file with awkward values. The decision rests on the shape of the data, not on taste. If the same tool had a flat list of settings and no steps, TOML would have been the answer.
A one-day experiment before you commit
If the choice is genuinely open, spend a day on it instead of a meeting. The experiment has five steps, and every step reuses material from this guide.
Write your real configuration in each candidate. Not a toy: take the most complicated file you expect to have, with its lists, nesting and optional values, and write it in two or three formats. Notice which are pleasant to write and which need workarounds. A config with a list of forty records that needs forty
[[tables]]has told you something.Run the awkward-values file through your parser. Use the test document from the parsing cluster: a version, a bare
no, a leading-zero number, a date, a null and a duplicate key. Record what your language's library does in each format. A format whose parser surprises you in your language is a worse choice for you, whatever it does elsewhere.Break the file on purpose. Misspell a key, put a string where a number belongs, leave a bracket unclosed. Compare the error messages and whether the program fails at startup or later. The format with the clearest errors in your toolchain wins ties.
Check the tooling in your editor and CI. Does the editor highlight, format and validate it? Is there a pre-commit hook? Can a JSON Schema attach to it? A format your tools do not understand is a format your reviewers will not read carefully.
Ask the people who will edit it. The operators, analysts or users who actually open the file are the readers that count, and a five-minute trial where they add a setting to each candidate is better evidence than any list of features.
The experiment usually settles the question, and when it does not, the formats are close enough that the choice matters less than the guardrails you put around it. Write down the decision and the reasons, so that the next person who proposes a change can see what was weighed.
Moving a repository from one format to another
If the experiment, or an incident, says you picked wrong, a staged migration is safer than a flag day. A sequence that has worked for configuration files:
Stage one, accept both. The loader reads the new format if the file exists and falls back to the old one, and logs a deprecation warning when it uses the old one. Nothing breaks, and the logs show who has not moved.
Stage two, convert and compare. Convert each file with a script, load old and new through the same typed model and assert that the loaded data is equal. This is the same round-trip check as in converting JSON to YAML without losing types, and it catches the nulls TOML cannot hold, the booleans INI turned into strings and the dates that changed type.
Stage three, switch the writers. Anything that generates the file (templates, scripts, bots) now writes the new format. Keep a CI check that rejects the old filename.
Stage four, remove the fallback. After a release or two with no deprecation warnings in the logs, delete the old reader and the old files.
Throughout, keep the schema the same. A schema that validates the loaded data is the contract that survives the change of syntax, and it is the reason to write one even if you never expect to migrate. The migration cost is mostly in the places that name the file: documentation, build scripts, deployment manifests and people's habits, which a search for the old filename finds.
The decision procedure
Does a platform mandate a format? Use it. Add validation and move on.
Does a program write the file, and is it read in bulk? JSON, JSON Lines or a binary format.
Is it a flat set of settings edited by hand? TOML, or INI if the ecosystem expects it.
Is it a tree with lists of records edited by hand? YAML, with quoting rules, a linter and a schema. Or TOML if the lists are short.
Do you need comments but cannot accept YAML's inference? TOML, JSON5 or StrictYAML for your own code's files.
Is it generated from many similar pieces? A configuration language or a generator, with a schema on the output.
Does it hold per-deployment values or secrets? Environment variables or a secrets manager, with typed coercion.
Whatever you pick, add the three guardrails: a schema or typed model, rejection of duplicate and unknown keys, and a test with the awkward values. Those protect you in every format.
What switching costs
Changing format later is feasible when the data model is simple. TOML to YAML is trivial, since both load to the same structures. YAML to TOML fails on nulls and on mapping keys that are not strings, and lists of records become [[tables]]. JSON to YAML works for all data and needs quoting care (converting JSON to YAML without losing types). INI to anything adds structure you must invent. The costly part is not the conversion but the consumers: every tool, script and document that mentions the file. Pick the format knowing that its replacement will take longer than its writing did. A conversion test that round-trips your real files, and a period where both formats are accepted, make the move safe.
Longevity
Formats and their parsers have lifetimes. YAML 1.2, TOML 1.0, JSON and INI are stable. Libraries vary: serde_yaml for Rust is marked deprecated, js-yaml changed what load means across major versions, and PyYAML changed its defaults in version 6. A format with several maintained implementations is safer for a long-lived project than one with a single parser and a single maintainer. Pin versions, keep the canary test file from the parsing cluster and rerun it on upgrades.
When this decision framework does not apply
You do not control the format. Platforms decide, and the question becomes how to make their YAML safe.
The file is a document. Prose with structure belongs in Markdown, reStructuredText or XML.
The data is tabular. CSV, Parquet or a database.
The configuration is a program. If it has loops and conditions, write it in a language with tests and generate the data format from it.
Your team already has a convention. Consistency across a codebase is worth more than the marginal advantage of a better format for one file. Change a convention deliberately, for all files, or not at all.
References: the YAML 1.2.2 specification, TOML 1.0, JSON (RFC 8259) and the Twelve-Factor App on configuration. Compare data quickly in the browser with the YAML to JSON converter and the TOML to JSON converter.


