DevKitLab Logo DevKitLab
JSON / YAML / TOML / Configuration

JSON vs YAML vs TOML: How to Choose a Config Format

A pull request adds one small config file and the review derails into forty comments — YAML? TOML? just use JSON? — and changes nothing. The thread jams on the wrong question: which format is best? There is no best. Each format's biggest strength comes bundled with a matching weakness, and once you see that, the choice comes down to two much simpler questions.

A pull request adds one small file — a config for a new service — and the review derails. “Why YAML? Nobody remembers the indentation rules.” “TOML’s cleaner, use that.” “Can we not add another format? We have JSON everywhere already.” Forty comments later the file hasn’t changed and nobody’s been convinced. The thread jammed on the question it always jams on: which format is best?

Here’s the thing: there is no best, and asking for one is exactly why the thread went nowhere. JSON, YAML, and TOML aren’t three contestants for a single title — they’re tuned for different jobs, and each of them buys its biggest strength with its biggest weakness. JSON’s refusal to carry comments is the same trait that makes it a clean wire format. YAML’s habit of guessing what you meant is the same trait behind its infamous footguns. TOML’s insistence that you spell everything out is the same trait that makes it verbose the moment your data gets deep. Once you see that none of them is free, “which is best” dissolves into two questions that actually have answers — and the format falls out of them:

  1. Is this a data file machines exchange, or a config file a person maintains?
  2. If a person maintains it, how much do you want the format to guess for you?

First, the same thing written three ways

Before the questions, look at one small config in all three. Same data — a service name, a port, a flag, a list:

{
  "name": "billing-api",
  "port": 8080,
  "debug": false,
  "allowedHosts": ["localhost", "127.0.0.1"]
}
name = "billing-api"
port = 8080
debug = false
allowedHosts = ["localhost", "127.0.0.1"]
name: billing-api
port: 8080
debug: false
allowedHosts:
  - localhost
  - 127.0.0.1

Line them up and the differences look cosmetic — braces versus key = value versus indentation. They are not cosmetic. Each layout encodes a different bet about who types into this file and who reads it back. To place that bet, you need the two questions.

Question 1: data for machines, or config for humans? The comment is the tell

A config file is read by a machine too, of course — that’s not the line. The line is who authors and maintains it: a data file is produced and consumed by programs moving information between systems, while a config file is written and tended by a person to steer a program. The fastest way to tell which side a file is on is to ask a single thing: is it allowed to have comments?

JSON has none. That isn’t an oversight — comments were left out by design, so that JSON could be a pure data-interchange format with a small, widely interoperable syntax and data model, and nothing inside that a reader might mistake for an instruction. A payload flying between two servers has no use for a # TODO. The strictness is the whole point: no comments, no trailing commas, every key quoted, one way to write each thing. That rigidity is precisely what you want when a machine on the far end has to parse it the same way every time — and it’s also why a stray comma or comment makes JSON refuse to parse at all.

Which is why JSON is an excellent data format and an awkward config format. The moment you’re hand-editing a JSON file and reach for a comment to explain why retries is 3, you’ve discovered the file isn’t data in transit — it’s configuration wearing a data format’s clothes. The ecosystem has quietly admitted this everywhere: tsconfig.json isn’t strict JSON, it’s JSONC (JSON with Comments); a long list of tools accept JSON5. Every one of those variants exists to add back the things JSON removed on purpose — because those files were config all along.

So Question 1 sorts cleanly:

  • It’s data moving between programs — an API response, a message on a queue, a cache entry, data you store or ship → JSON. Its lack of comments and its pedantry are features here. Reaching for YAML or TOML would trade strictness you want for flexibility no machine asked for.
  • It’s config a person maintains — a service config, a CI pipeline, a tool’s settings → go to Question 2.

One honest caveat: plenty of config does live in JSON — package.json, .eslintrc.json — and that’s fine when the file is mostly machine-managed and humans touch it rarely. The friction scales with how often a person hand-edits it. package.json is mostly written by your package manager; tsconfig.json is written by you, which is exactly why the tooling had to bolt comments back on.

Question 2: how much should the format guess? YAML vs TOML

Once it’s a config file, the real choice is YAML versus TOML, and it comes down to one trait: how much the format guesses for you.

YAML guesses the most. You write port: 8080 and it decides that’s a number; debug: false and it’s a boolean; host: localhost and it’s a string. You rarely type a quote or a bracket. That inference is what makes YAML feel so light to write — and, as the next section shows, it’s the source of its most infamous footguns.

TOML guesses the least of the config formats, on purpose. Its stance is be explicit, but stay comfortable: strings are quoted like in JSON, numbers and booleans and dates have unambiguous forms, and — crucially — it keeps the things JSON refused, namely comments and human-friendly [section] grouping. A bare word it can’t classify isn’t quietly coerced; it’s a parse error.

So among config formats the split is about shape and depth:

  • Config that’s mostly flat — a list of settings, a few grouped sections like [database], [server], [logging]TOML. It reads like an INI file that grew up: obvious, greppable, hard to misread. This is why Rust’s Cargo.toml, Python’s pyproject.toml, and countless CLIs settled on it.
  • Config that’s deeply nested — trees of maps, lists of objects, values you want to reuse (Kubernetes manifests, Ansible playbooks, GitHub Actions workflows, Docker Compose) → YAML. Indentation lays out a deep tree far more readably than TOML’s repeated headers, and YAML has real machinery — anchors and aliases — for reuse.

The footgun that earns YAML its reputation: type coercion

This is where YAML’s guessing turns from convenience into incident. The famous case is the Norway problem:

countries:
  - NO   # Norway... or the boolean false?

In YAML 1.1 — the version many widely used parsers still apply by default, PyYAML among them — NO is not the string "NO". It’s the boolean false. The same coercion catches yes, no, on, off, y, and n — all read as booleans rather than text. A config listing country codes silently drops Norway and hands your program false. (YAML 1.2’s core schema dropped this rule — there, NO stays a string — but plenty of runtimes still default to 1.1 behavior, so you can’t assume you’re safe.)

It doesn’t stop at booleans, and it isn’t only a 1.1 story. Some traps fire under YAML 1.2’s core schema too, because the token genuinely looks like a number:

version: 1.10    # 1.1 and 1.2 core: the number 1.1 — the trailing zero is gone
build:   1e5     # 1.1 and 1.2 core: 100000 — read as scientific notation
time:    22:22   # YAML 1.1 only: 1342 (base-60: 22×60 + 22); a string under 1.2 core

These usually don’t error. Which value coerces, and to what, depends on the parser and the schema it applies — 22:22 is sexagesimal only under YAML 1.1, while 1.10 and 1e5 are numbers under 1.2’s core schema too. When a coercion does fire, the wrong type slips in silently, and you learn about it in production — when a version check compares the number 1.1 against the string "1.10" and disagrees.

The fix is a one-line habit: quote anything a human reads as text but a parser might read as something else — country codes, version strings, git SHAs, ports with leading zeros, times. version: "1.10", - "NO". YAML is safe once you treat its guessing as something to guard against rather than lean on. You just have to know to guard. (For the full field guide to YAML’s failures — the loud parse errors and these silent coercions — see why does my YAML break.)

Now the contrast that explains why TOML feels safer:

country = NO       # error: TOML won't guess — write "NO", or it refuses to parse
port    = 0700     # error: leading zeroes are not allowed

TOML turns YAML’s silent wrong answer into a loud parse error. A bare word it can’t classify doesn’t become false; it produces a clear parse error the moment the config is read. That’s the appeal in one line — you can’t accidentally ship the Norway problem in TOML, because the ambiguous form simply isn’t valid.

But TOML isn’t magic, and here’s the honest edge: things shaped exactly like a number still parse as numbers.

version = 1.10     # still becomes the float 1.1 — for the string, write "1.10"

The difference is predictability, not immunity. TOML’s rule is short — if it looks exactly like a number it is a number, and anything else must be quoted or it’s an error — whereas YAML carries a long, surprising list of bare words (NO, on, 22:22) that mean something other than themselves. A short rule you can hold in your head beats a long one that surprises you.

The bill for TOML’s virtue: depth

TOML’s explicitness has its own cost, and it comes due when the data gets deep. Watch the same list of nested objects in both:

[[server]]
name = "alpha"
[server.limits]
maxConns = 100

[[server]]
name = "beta"
[server.limits]
maxConns = 200
server:
  - name: alpha
    limits:
      maxConns: 100
  - name: beta
    limits:
      maxConns: 200

TOML’s [[server]] and [server.limits] headers are unambiguous, but they repeat, and the reader has to reassemble the tree in their head. In YAML, the indentation is the tree. This is why deeply nested data is where people reach past TOML for JSON or YAML: flat to shallow, TOML is a pleasure; several levels deep, it fights you, and YAML (quoted defensively) reads better.

The one principle underneath all of it

Step back and the three line up on a single axis — how much they leave to inference — and every property, good and bad, follows from where a format sits on it:

Guesses…You getYou pay
JSONnothing; everything explicit, no commentsa strict, widely interoperable wire formatpainful to hand-edit — no comments, no trailing commas
TOMLalmost nothing; ambiguity is an errorsafe, obvious, greppable configverbose once the data nests deeply
YAMLthe most; infers types from bare wordsthe lightest thing to write, scales to deep treesthe Norway problem and silent type coercion

There’s no row without a cost. That’s what the original argument missed: you’re not hunting for the format with no weaknesses, you’re choosing which weakness you can live with for this file. A wire format that’s annoying to hand-edit is fine — you rarely hand-edit it. A config format that’s verbose when nested is fine — if your config is flat. A config format that guesses types is fine — if you quote defensively and your reviewers know to look.

A decision table

The file is…UseBecause
Data machines exchange (API, queue, storage)JSONstrictness and broad interoperability are the job; comments aren’t needed
Human config, mostly flat sectionsTOMLexplicit and unambiguous — it can’t hit the Norway problem
Human config, deeply nested or reuse-heavyYAMLindentation scales to deep trees; anchors for reuse — quote defensively
Human config you must hand-edit but it’s stuck in JSONJSONC / JSON5you’ve conceded it’s config; add comments back

The converters here are built to surface exactly these traps, not just to round-trip text. Paste into the YAML converter and its checks panel flags the YAML 1.1 boolean trap (that unquoted no) and version numbers about to collapse into floats; paste into the TOML converter and it flags date-times downgraded to ISO strings and integers beyond 2^53 that the converter keeps as strings to avoid precision loss — the exact spots where the format you’re leaving and the one you’re entering disagree. Both default to YAML 1.2 core / standard TOML — an explicit %YAML 1.1 directive in the document still switches YAML back to 1.1 behavior — and only flag the risks rather than blocking, so what a different runtime does still depends on its parser and schema, but seeing the diff up front beats meeting it in production. And if you just need to tidy or validate a JSON blob, the JSON formatter will do that too.

The checklist

Next time a thread stalls on “which format,” don’t argue best. Ask, in order:

  1. Data or config? Data machines exchange → JSON, and stop. Its strictness is the feature, not a flaw to fix.
  2. (Human) flat or deep? Mostly flat sections → TOML. Deeply nested or reuse-heavy → YAML.
  3. Did you quote the ambiguous scalars? In YAML especially — country codes, versions, SHAs, times, leading-zero numbers. Quote them, or the parser guesses for you.
  4. Are you fighting the format? Wanting comments in JSON, or drowning in [[section]] headers in TOML, means the file has outgrown its format. That’s the signal to switch, not to suffer.

Underneath all four is the one line worth keeping: there is no best config format, only the one whose built-in cost you can afford for this file. Answer “data or config,” then “flat or deep,” quote what YAML would otherwise guess, and the choice stops being a matter of taste and becomes one you can defend in a sentence.