Skip to content
jsonbeautifiers
English

JSON, YAML or TOML: which to reach for

These three formats differ less in what they can express than in how they fail, and the failures are what you will spend your time on.

Every claim here is either measured or sourced. Where it is neither, it says so.

A deploy pipeline reads a country list out of a YAML file. Someone adds Norway with its ISO code, NO, and the pipeline starts skipping that market with no error anywhere. The value that arrived at the application was the boolean false.

That is the sort of thing that should decide which format you pick, not a table of “supports comments: yes/no”. All three formats can hold a map of strings to values. What separates them is what they do to you when nobody is watching.

JSON: boring, and that is the whole point

JSON is a wire format. It has six types, four permitted whitespace characters (space, tab, carriage return, line feed, per RFC 8259), no comments, no trailing commas, no date type, and exactly one number type that every reader is free to interpret as a float64. It is under-specified in a couple of places that matter, notably duplicate keys, where the RFC says keys SHOULD be unique and then leaves the behaviour undefined. JavaScript and Python both keep the last one.

Its virtues are entirely non-technical. Every language ships a parser in its standard library. Every HTTP client knows what to do with it. There is essentially no version skew: a JSON document written in 2008 parses today, identically, everywhere. When you serialise for a network hop, a log line, a message queue or a cache, none of the human-facing features of the other two formats buy you anything, and the universality buys you a lot.

The failure modes are well-trodden and mostly about numbers. Number.MAX_SAFE_INTEGER is 9007199254740991, and IDs above it get silently rewritten, which is its own article. Dates are strings by convention and nothing enforces the convention, which is also its own article. Neither is a reason to pick a different format for transport. They are reasons to be careful.

YAML: real ergonomics, real bill

People do not choose YAML because it is elegant. They choose it because a Kubernetes manifest or a CI pipeline is a thing a human edits by hand every day, and JSON is genuinely unpleasant to edit by hand: no comments, mandatory quotes, and a missing comma four hundred lines up. YAML gives you comments, multi-line strings that are readable, and no punctuation noise. Those are worth something.

Here is what you are paying.

The Norway problem

YAML 1.1 resolves bare no, yes, on, off, y and n as booleans. YAML 1.2’s core schema does not, and leaves them as strings. The same document, the same key, two answers:

a: no

Under YAML 1.2 core resolution that value is the string "no". Under 1.1 rules it is boolean false. Which one you get depends on your library, not on your file: PyYAML and Ruby’s Psych resolve on 1.1 rules, while js-yaml follows 1.2. Go’s yaml.v3 sits in between, resolving no as a string unless the target field is a typed bool, in which case it still accepts the 1.1 spelling. A Python service and a Node service reading the same config file disagree about the value, and neither logs anything.

The fix is to quote every string that could be mistaken for something else. Country codes, version numbers (1.10 is a float, "1.10" is not), anything starting with a zero, and any value a user supplies. If you generate YAML programmatically, make the emitter quote defensively rather than trusting your own review.

Whitespace is syntax and tabs are illegal

Indentation carries structure, so a misaligned line is a different document rather than an error. Worse, the YAML spec forbids tab characters for indentation outright. An editor configured to insert a tab produces a file that fails to parse with a message about a character that is invisible in your terminal. Set your editor per file type and stop thinking about it.

Anchors expand on the way out

Anchors and aliases let you define a block once and reuse it:

defaults: &defaults
  timeout: 30
  retries: 3

staging:
  <<: *defaults
  host: stage.internal

This is the feature that sells YAML to people maintaining forty near-identical service definitions. It is also a feature the data model does not have. Convert that file to JSON and the merge key is resolved, the alias is expanded, and defaults appears in full inside staging. Round-trip it back to YAML and you get two literal copies. Nothing is wrong, exactly, but the thing you were maintaining is gone. A YAML file that leans on anchors is not really convertible, it is only readable once.

yaml.load executes your config

Full YAML supports language-specific tags that construct arbitrary objects. In Python that means a document containing !!python/object/apply:os.system can run a command during parsing. yaml.safe_load is the version that only builds standard types, and it is the one you want for anything you did not write yourself. PyYAML eventually made this hard to get wrong by requiring an explicit loader argument, but plenty of code predates that, and plenty of other languages still have an unsafe default sitting one function call away.

import yaml

with open("config.yaml") as f:
    cfg = yaml.safe_load(f)   # not yaml.load

The superset detail

YAML 1.2 was designed as a superset of JSON, and the spec states that every valid JSON document is also a valid YAML 1.2 document, so a 1.2 parser reads your JSON. YAML 1.1 is not quite: it wants a space after the colon, so a compact {"a":1} is a parse error there, and the 1.1 resolution rules will still turn some of your strings into booleans. If you are relying on “just feed the JSON to the YAML parser”, check which version your library implements first. Either way you can go the other direction cleanly with the YAML to JSON converter.

TOML: unambiguous until it nests

TOML exists because INI files were pleasant and imprecise. It fixes the imprecision: integers and floats are distinct types, booleans are only true and false, and there are four real date and time types (offset date-time, local date-time, local date, local time) built into the grammar rather than smuggled through strings. Comments are first class. Defining the same key twice is a hard error rather than undefined behaviour, which is a small thing that catches a real class of merge mistake.

For a flat or shallow config it is the best of the three. Cargo.toml and pyproject.toml are the obvious cases: a few sections, string and list values, occasional nesting one level deep. Nothing is ambiguous and nothing needs quoting for safety.

It gets ugly fast when the data is a tree. Deep nesting means either long dotted headers or long dotted keys:

[servers.production.database.replica]
host = "10.0.0.4"
port = 5432

And an array of objects needs the double-bracketed array-of-tables form, repeated per element:

[[targets]]
name = "web"
port = 8080

[[targets]]
name = "worker"
port = 8081

That reads fine at two entries. At thirty entries with three fields each, with inline tables that must fit on one line, you are fighting the format. If your configuration is genuinely hierarchical, TOML is the wrong shape and you will feel it every time you add a level.

What none of them give you

A decimal type. All three give you a float, which is a binary approximation. Money still belongs in minor units as an integer or in a string.

Binary data. JSON and TOML have no representation at all, so it is base64 in a string. YAML has a !!binary tag, which works and does not survive conversion to either of the others.

A schema that comes with the format. JSON Schema is the mature option, and because YAML 1.2 maps onto the same data model you can validate YAML with it too. That is how most YAML validation actually works. TOML has no equivalent with comparable adoption.

Comments through a conversion. This is the one-way door. Comments live in the syntax, not in the data model, so a YAML or TOML file converted to JSON loses every comment permanently, and there is no clever tooling that gets them back. If a file’s comments are load-bearing, its source of truth is that file and JSON is only an artefact you generate. JSON’s lack of comments is deliberate, and it is the reason this asymmetry exists.

Choosing, as questions

Is a machine the only reader? JSON. Do not make an API speak YAML.

Will a human edit it weekly, and is it hierarchical? YAML, with quoting discipline and safe_load.

Will a human edit it, and is it mostly flat sections of scalars? TOML. You lose nothing and you gain unambiguous types.

Do you need comments to survive? Whatever you pick, that file is the source of truth. Generate downward, never edit the generated copy.

Are non-developers or a UI producing the values? JSON, generated by a program, validated against a schema. Every YAML trap above is triggered by a string somebody typed.

Are you converting between them right now? Do it in the JSON to YAML converter and read the output rather than trusting it, especially the booleans, and run the result through the validator before it reaches anything that deploys.