Published
JSON, YAML and TOML describe roughly the same tree of maps, lists and scalars, which is why converters between them are easy to write and easy to trust too much. The interesting parts are the edges: YAML infers types from unquoted text, TOML has no null, and only two of the three can carry a comment. This guide is about those edges.
Three formats, three jobs
JSON is an interchange format. It was designed for machines to write and machines to read, and its complete grammar fits on one page. That minimalism is exactly why it is a poor configuration language: you cannot leave a note next to a setting, and a single misplaced comma in a hand-edited file breaks a deploy.
YAML is a human-authoring format with very large ambitions. It supports comments, multiple documents in one file, anchors and references for reuse, block scalars for embedded text, and custom tags. The specification is long, and the cost of that expressiveness is that plain unquoted text is interpreted rather than taken literally. Every YAML footgun in the next section is a consequence of that one decision.
TOML sits deliberately between the two. It was designed to be an obvious, minimal configuration format with a well-defined mapping to a hash table. It has comments, first-class typed dates, and no significant indentation, which means a mis-indented line is a syntax error rather than a different configuration. Its weakness is deep nesting: past two or three levels the table-header syntax becomes noisier than the structure it describes.
| Capability | JSON | YAML | TOML |
|---|---|---|---|
| Comments | None | `#` to end of line | `#` to end of line |
| Null value | `null` | `null`, `~`, or empty | No representation at all |
| Date and time type | String only | Timestamp in YAML 1.1 schemas | First-class, four variants |
| Indentation significant | No | Yes — tabs are forbidden | No |
| Multiple documents per file | No | Yes, separated by `---` | No |
| Reuse / references | No | Anchors `&a`, aliases `*a`, merge `<<` | No |
| Multi-line strings | `\n` escapes only | Block scalars `|` and `>` | Triple-quoted `"""` and `'''` |
| Duplicate keys | Undefined; most parsers keep the last | Spec says error; many parsers keep the last | Defined error |
| Unquoted scalars retyped | Not applicable | Yes — the main hazard | No |
| Trailing comma in arrays | Rejected | Not applicable | Allowed in arrays, not inline tables |
The same configuration in all three
Comparing the same small document side by side makes the structural trade-off concrete. Note the ordering constraint in the TOML version: every scalar key belonging to `[service]` must appear before the `[service.retry]` header, because a table header ends the previous table. Converters get this right; people hand-editing the result frequently do not.
Notice also what happens to the array. JSON and TOML both write it inline. YAML offers a block sequence and an inline flow sequence, and most converters emit the block form, which is more readable but roughly doubles the line count. Neither is more correct.
JSON
{
"service": {
"name": "checkout",
"port": 8080,
"debug": false,
"tags": ["eu", "beta"],
"retry": { "attempts": 3, "backoff": "250ms" }
}
}
YAML
service:
name: checkout
port: 8080
debug: false
tags:
- eu
- beta
retry:
attempts: 3
backoff: 250ms
TOML
[service]
name = "checkout"
port = 8080
debug = false
tags = ["eu", "beta"]
[service.retry]
attempts = 3
backoff = "250ms"The YAML footguns, and why they exist
YAML resolves an unquoted scalar by matching it against a set of regular expressions. If the text looks like a boolean, it becomes a boolean; if it looks like a number, it becomes a number; otherwise it is a string. The rules differ between YAML 1.1 and YAML 1.2, and which one applies depends on your parser, not on your file. PyYAML and libyaml implement 1.1. `gopkg.in/yaml.v3` moved to the 1.2 core schema for booleans, while `yaml.v2` did not. The same file can therefore mean different things in two services that both claim to read YAML.
The famous case is the Norway problem. Under YAML 1.1, `y`, `yes`, `on`, `n`, `no` and `off` are booleans. A list of ISO country codes containing `NO` for Norway parses as `False`. The fix is trivial once you know — quote it — but nothing in the file hints that anything happened.
Version numbers are the second-most common casualty. `version: 1.10` is a float, and the float 1.10 is 1.1, so a pinned version silently becomes an earlier one. Leading zeros are worse: under YAML 1.1 a value like `010` matches the octal pattern and resolves to the integer 8, which has ruined more than one zero-padded account number. And YAML 1.1's sexagesimal integers mean an unquoted `12:30:00` becomes 45000, the number of seconds.
The rule that prevents all of this is mechanical: quote every scalar whose value is not semantically a number or a boolean. Identifiers, version strings, country codes, ports written as text, times, and anything with a leading zero all get quotes. It costs two characters and removes an entire class of incident.
The middle column is the result under a YAML 1.1 parser such as PyYAML. A YAML 1.2 core-schema parser keeps several of these as strings — which is itself the problem, since you cannot tell from the file which you will get.
| Written in the file | Parsed as (YAML 1.1) | Write this instead |
|---|---|---|
| `country: NO` | boolean `false` | `country: "NO"` |
| `enabled: on` | boolean `true` | `enabled: true` |
| `answer: y` | boolean `true` | `answer: "y"` |
| `version: 1.10` | float `1.1` | `version: "1.10"` |
| `account: 010` | integer `8` (octal) | `account: "010"` |
| `offset: 12:30:00` | integer `45000` (base 60) | `offset: "12:30:00"` |
| `value: ~` | null | `value: "~"` if you meant a tilde |
| `ratio: .5` | string `".5"` in 1.1 | `ratio: 0.5` |
| `sha: 1e10` | float `10000000000.0` | `sha: "1e10"` |
TOML's shape, and where it gets awkward
TOML 1.0.0 fixed the format in 2021, so the version churn that plagued early adopters is over. Its type system is the richest of the three: strings, integers guaranteed to at least 64 bits signed, floats, booleans, offset date-times, local date-times, local dates, local times, arrays, inline tables, and arrays of tables. A date in TOML is a date, not a string that everyone agrees to parse the same way.
The syntax is line-oriented and unambiguous. Keys may be bare, quoted, or dotted; `a.b.c = 1` creates the nested tables implicitly. Arrays may span lines and may carry a trailing comma; inline tables must fit on one line and may not. Repeated `[[products]]` headers build an array of tables, which is TOML's answer to a list of objects and is genuinely pleasant for that shape.
The awkwardness appears with depth and with heterogeneous nesting. A three-level structure needs headers like `[tool.poetry.dependencies]`, and a list of objects each containing a list of objects becomes hard to follow. TOML also has no null: a key is present or absent, full stop. That is a clean model, but it means `{"retries": null}` from JSON has no faithful TOML form — a converter must either omit the key or invent a sentinel, and those are different configurations.
# Array of tables — the idiomatic TOML shape for a list of objects
[[server]]
host = "eu-1.example.com"
port = 8443
enabled = true
[[server]]
host = "us-1.example.com"
port = 8443
enabled = false
# Dotted keys create the same structure as a header
owner.name = "Ada"
owner.since = 2026-03-01 # a real date, not a string
# Equivalent JSON
# {
# "server": [
# {"host": "eu-1.example.com", "port": 8443, "enabled": true},
# {"host": "us-1.example.com", "port": 8443, "enabled": false}
# ],
# "owner": {"name": "Ada", "since": "2026-03-01"}
# }
# Note the date: JSON has no date type, so it can only become a string.What a conversion drops
Every converter works by parsing into an in-memory tree and re-printing it. Anything that is not part of that tree is gone. Comments are the big one: convert a commented YAML file to JSON and back and you have lost the reason every setting exists. If the file is maintained by hand, treat conversion as one-way and keep the source of truth in the format people edit.
YAML anchors and aliases also collapse. An anchor is a reuse mechanism, not a data feature, so `&defaults` / `*defaults` expands into duplicated content in the output. The result is semantically identical and structurally much larger, and a subsequent edit to the expanded copy no longer propagates. Merge keys (`<<: *base`) behave the same way. Custom tags such as `!Ref` — heavily used by CloudFormation and some CI systems — have no target representation at all and will either be dropped or turned into an unhelpful string.
- Comments: lost in every direction that targets JSON.
- YAML anchors and merge keys: expanded, never preserved.
- YAML custom tags (`!Ref`, `!!python/object`): no equivalent anywhere.
- TOML dates and times: become strings in JSON and YAML.
- JSON `null`: has no TOML representation; the key must be dropped.
- Key order: preserved by most converters, guaranteed by none.
Safety: YAML parsing is not a neutral operation
YAML's tag system allows a document to request construction of arbitrary objects. PyYAML's `yaml.load` historically honoured tags such as `!!python/object/apply`, which is remote code execution if the document came from anywhere you do not control. Always use `yaml.safe_load`, or `yaml.load(..., Loader=yaml.SafeLoader)`. Other languages have equivalent distinctions; check which loader your library uses by default before parsing user-supplied YAML.
Neither problem exists in JSON or TOML, which have no reuse or construction mechanism. If a configuration file arrives from outside your trust boundary and you have a choice of format, that alone is a reason to prefer one of those two.
What to remember
- Quote every YAML scalar that is not semantically a number or a boolean — country codes, version strings, zero-padded identifiers and times — because unquoted text is retyped by rules that differ between YAML 1.1 and 1.2 parsers.
- Choose TOML for mostly flat configuration a person maintains, YAML when nesting is deep or the ecosystem already picked it, and JSON when a program writes the file.
- Treat any conversion that targets JSON as one-way: comments, YAML anchors and custom tags do not survive, so keep the source of truth in the format your team actually edits.
- Remember that TOML has no null, so a JSON key with a `null` value must be dropped or replaced by a sentinel, and those two outcomes are different configurations.
- Use `safe_load` or the equivalent for any YAML you did not write yourself, and verify a conversion by round-tripping it and diffing against the original.