JSON Toolkit

JSON to YAML on the Command Line: Four Traps Behind One yq Command

Bottom line: The most-copied command in every tutorial, yq -P input.json > output.yaml, still emits JSON on yq v4. This article benchmarks yq 4.44.6, PyYAML 6.0.3 and js-yaml on a 2.39 MB sample, explains why the input extension decides the output format, and gives correct recipes for batch conversion, quoting, indentation and big-integer precision.

Intermediate~9 min readPublished 2026-11-03Updated 2026-11-03
Cover comparing a JSON-to-YAML command and its output in a terminal

Bottom line: yq -P input.json > output.yaml — the single most copied command in tutorials — still produces JSON on yq v4. To get YAML you must pass -o=yaml (or -oy) explicitly. -P is short for --prettyPrint: it controls indentation and block style, not the output format.

Converting JSON to YAML was never a matter of changing a file extension. The real cost hides in four places: the input extension decides the output format, quoting rules silently rewrite your strings, sequence indentation affects downstream tools, and a large integer that passes through yq loses precision permanently.

1. Run the popular command once

On a clean machine with yq v4.44.6 installed, create a minimal input:

printf '{"a":1,"b":{"c":2}}' > s.json
yq -P '.' s.json

What comes back is not YAML. It is indented JSON:

{
  "a": 1,
  "b": {
    "c": 2
  }
}

If you follow the tutorial and redirect that into output.yaml, you get a file with a .yaml extension holding JSON. Every YAML 1.2 parser reads it happily (JSON is a subset of YAML 1.2 — the full containment argument lives in JSON, YAML and XML conversion boundaries), so nothing breaks immediately. It breaks the day someone adds a comment or an anchor, or when yamllint in CI starts emitting a wall of truthy value should be one of.

The invocation that actually produces YAML names the output format:

yq -o=yaml '.' s.json
# a: 1
# b:
#   c: 2

2. Why: the extension decides the output format

yq v4 follows a "input format decides output format" rule. It infers the format from the input file extension and, without -o, reuses that inference. .json in → JSON out. .yaml in → YAML out. Reading from stdin with no extension, it defaults to YAML.

Four invocations against the same input, measured:

Command Input source Actual output format Verdict
yq -P '.' s.json s.json file JSON (indented) Trap when redirected to .yaml
yq -o=yaml '.' s.json s.json file YAML (block style) Correct
yq -P -o=yaml '.' s.json s.json file YAML (block style) Correct, -P redundant here
cat s.json | yq -P '.' stdin, no extension YAML (block style) Correct, YAML is the default

Row three tells you -P is not always necessary once -o=yaml is present — and row six of this article shows it is not harmless either. Row four explains why copying tutorials often appears to work: many people pipe data in, which happens to bypass the extension branch entirely.

3. Batch conversion: three shell recipes

Converting one file is the starting point only. Almost every real reason to do this on the command line is batch: every config in a directory, every captured API snapshot, all at once.

bash / zsh loop (most readable, single directory):

for f in configs/*.json; do
  yq -o=yaml '.' "$f" > "${f%.json}.yaml"
done

find, recursive (when subdirectories matter):

find . -name '*.json' -exec sh -c 'yq -o=yaml "." "$1" > "${1%.json}.yaml"' _ {} \;

PowerShell (on Windows CI):

Get-ChildItem *.json | ForEach-Object { yq -o=yaml '.' $_.FullName > ($_.BaseName + '.yaml') }

All three share one hard prerequisite: validate the source JSON first. A converter is not a parser. Given a syntax error, many converters emit a vague position instead of the line-level detail a validator gives you. Adding one guard inside the loop costs almost nothing:

for f in configs/*.json; do
  jq empty "$f" || { echo "invalid: $f"; continue; }
  yq -o=yaml '.' "$f" > "${f%.json}.yaml"
done

jq empty parses without printing and exits non-zero on invalid JSON. Combined with set -euo pipefail, the pipeline stops at the first bad file instead of producing a pile of half-converted output.

4. Four output flags, four separate jobs

yq has a long flag list, but only four matter on this path:

Flag What it does When you must pass it
-o=yaml / -oy Sets the output format to YAML Whenever input is a .json file
-P / --prettyPrint Forces block style, normalizes indentation Reading stdin, or when sequence indentation matters
-I=n Indentation width, default 2 Downstream wants 4 spaces (some Ansible style guides)
--split-exp Splits array elements into documents Generating multi-resource Kubernetes manifests

--split-exp deserves a note. Kubernetes manifests conventionally separate resources with ---. Generating that from a JSON array:

yq -P -o=yaml '.[] | splitDoc' --split-exp 'true' kubernetes-list.json

Without --split-exp you get one YAML sequence; with it, each array element becomes its own ----separated document. kubectl apply accepts both, but the diffs differ by an order of magnitude in readability.

5. Quoting: six string shapes that must be quoted

YAML type inference is implicit. A string that looks like a number or a boolean gets parsed as one unless it is quoted. This is not theoretical: measured on yq v4.44.6, all six of the following get quoted automatically, which proves the parser really would misread them:

printf '{"version":"1.0","enabled":"true","port":"8080","empty":"","colon":"host: value","hash":"v # c","unicode":"端口 8080","ts":"2026-11-03"}' | yq -P -o=yaml '.'
version: "1.0"
enabled: "true"
port: "8080"
empty: ""
colon: 'host: value'
hash: 'v # c'
unicode: 端口 8080
ts: "2026-11-03"

The consequences, one by one:

Original value (JSON string) Parsed as, without quotes Real impact
"1.0" Float 1.0 Version becomes a decimal; 1.10 collapses to 1.1
"true" Boolean true Branching logic inverts or types mismatch
"8080" Integer 8080 Port changes type, string comparison fails
"" null Empty string vanishes, downstream gets None
"host: value" Parse error or nested mapping A colon must be followed by a space
"2026-11-03" Date object Deserializes to datetime.date, not a string

That last one is the nastiest. In JSON a date is just a string; after conversion it becomes a YAML date, and many implementations serialize it back as a date object rather than the original string, breaking round-trip fidelity. Both PyYAML and yq add quotes here, which is an admission that the risk is real.

Note also that unicode: 端口 8080 is emitted unquoted — CJK text is safe in YAML. What you should worry about is key order and precision, covered next.

6. Python and Node equivalents, with measured timings

You cannot install yq everywhere: air-gapped boxes, locked-down CI images, Windows without a package manager. Python and Node equivalents exist, each with its own defaults waiting to bite. For a scenario-by-scenario comparison of all three routes see JSON CLI tools and editor selection; this article stays focused on the JSON → YAML direction.

Python + PyYAML (three arguments, none optional):

import json, yaml

with open("config.json", encoding="utf-8") as f:
    data = json.load(f)

with open("config.yaml", "w", encoding="utf-8") as f:
    yaml.safe_dump(
        data, f,
        default_flow_style=False,  # block style, not {a: 1, b: 2}
        allow_unicode=True,        # keep CJK as-is, not \u7AEF\u53E3
        sort_keys=False,           # preserve the original key order
    )

Those three default to False / False / True, which means out-of-the-box PyYAML sorts keys alphabetically and escapes CJK into \uXXXX. Measured on the same input with defaults:

apiVersion: v1
data:
  LOG_LEVEL: info
  colon: 'host: value'
  unicode: "\u7AEF\u53E3 8080"
kind: ConfigMap
metadata:
  labels:
    app: web
  name: app-config

Notice kind now sorts after apiVersion and before metadata. Kubernetes convention puts apiVersion before kind; reordering does not break parsing, but it makes review miserable.

Node + js-yaml:

const fs = require("fs");
const yaml = require("js-yaml");

const data = JSON.parse(fs.readFileSync("config.json", "utf8"));
fs.writeFileSync(
  "config.yaml",
  yaml.dump(data, { lineWidth: -1, noRefs: true, sortKeys: false })
);

lineWidth: -1 disables line folding (otherwise long URLs get broken mid-string) and noRefs: true suppresses anchors and aliases — required when the consumer is kubectl or another tool that does not resolve them.

Measured timings (sample: 2.39 MB JSON, 12,000 records, each with a nested object and two arrays; machine is the sandbox used for this task):

Implementation Convert Parse back Round-trip
yq -o=yaml v4.44.6 0.45 s 0.45 s Identical
yq -P -o=yaml v4.44.6 4.16 s — Identical
Node js-yaml 4.x 0.23 s 0.11 s Identical
Python PyYAML 6.0.3 3.26 s 8.01 s Identical

Two counter-intuitive results. First, adding -P makes yq roughly 9× slower (0.45 s → 4.16 s) while producing byte-identical output on this sample (2,326,895 bytes both ways) — -P performs a full re-layout pass, and that adds up across a few hundred files. Second, PyYAML's reverse parse (8.01 s) is more than twice its forward dump (3.26 s), so Python is a poor choice if your pipeline reads YAML back repeatedly.

One more: 2.39 MB of JSON became 2.33 MB of block-style YAML, about 3% smaller. The folklore that "YAML is always bigger than JSON" does not hold — at depth, JSON's quotes, commas and braces can outweigh YAML's indentation whitespace.

7. Three things that don't come back

The previous sections cover converting correctly. This one covers what gets lost. A purpose-built input, measured:

printf '{"order_id":12345678901234567890,"zip":"0150","ts":"2026-11-03","f":1.0e6}' > t.json
yq -o=yaml '.' t.json

yq emits:

order_id: 1.2345678901234567e+19
zip: "0150"
ts: "2026-11-03"
f: 1000000

And converted back: {"order_id":12345678901234567000,"zip":"0150","ts":"2026-11-03","f":1000000}.

The 20-digit order id 12345678901234567890 became 12345678901234567000 — the last three digits zeroed, with no warning at all. yq parses through Go's float64 path; integers beyond 2^53 cannot be represented exactly, so it falls back to scientific notation and the original value is gone by the time you convert back.

The same input through PyYAML:

order_id: 12345678901234567890
zip: '0150'
ts: '2026-11-03'
f: 1000000.0

Python integers have arbitrary precision, so all 20 digits survive and round-trip intact. If your data contains order ids, snowflake ids or national id numbers, do not use yq — use Python, or store the field as a string first. The full mechanism behind this class of failure is explained in Why large integers lose precision in JSON.

The second loss is the float/int distinction: 1.0e6 becomes 1000000 in yq output while PyYAML keeps 1000000.0. The third is key order — PyYAML sorts by default, yq and js-yaml preserve by default, which is a hidden source of disagreement in cross-language teams.

8. In CI, don't just convert: validate, convert, lint

Treating conversion as the only step is the most common pipeline defect. A zero exit code means "the input JSON parsed", not "the output is correct" or "the output satisfies the consumer". The correct three-stage shape:

#!/usr/bin/env bash
set -euo pipefail

# 1. Validate the source: syntax layer
jq empty deployment.json

# 2. Convert: name the output format explicitly
yq -o=yaml '.' deployment.json > deployment.yaml

# 3. Validate the artifact: parseable YAML, and lint-clean
yq -o=json -I=0 '.' deployment.yaml > /dev/null
yamllint deployment.yaml

That yq -o=json ... > /dev/null in step 3 looks redundant and is the most useful line in the block: it forces the artifact to be re-parsed. Indentation mistakes, anchor resolution failures and rewritten types surface right there. Skip it and the problem waits until kubectl apply or application startup.

If the artifact is a human-maintained config — Helm values, docker-compose, GitHub Actions workflows — confirm three more things: key order was not scrambled, long numbers did not lose digits, and comments still need to be added by hand. JSON has no comments, so every annotation has to be written after conversion; no converter will invent them for you.

FAQ

Should I use yq -P or -o=yaml?

-o=yaml decides whether the output is YAML; -P decides what the YAML looks like. With a .json input, -o=yaml is required, and -P is only needed when you want forced block style or sequence indentation. Reading from stdin with no extension, yq already defaults to YAML, so -P alone is enough.

Can I feed the converted YAML straight to Kubernetes?

Syntactically yes, with three caveats: keys with ordering conventions such as apiVersion were not re-sorted (PyYAML sorts by default; set sort_keys=False), no anchors or aliases were generated (js-yaml: set noRefs: true), and long numbers kept their digits. For multi-resource manifests, split into ----separated documents with --split-exp.

Why is my output one line, {a: 1, b: 2}, instead of indented?

That is flow style. It is valid YAML but defeats the purpose of converting. Use -P to force block style; with PyYAML, confirm default_flow_style=False (it has defaulted to False since PyYAML 5.1, older versions need it explicitly).

My 20-digit order id lost its last digits. Can I recover them?

No — prevent it instead. yq parses through float64, so integers beyond 2^53 lose precision by construction. Either switch to Python (arbitrary-precision integers; verified lossless) or store long numbers as strings in the JSON, which also fixes the equivalent JSON.parse problem on the JavaScript side.

Online converter or command line?

For secrets-bearing configs and API snapshots with tokens, use the command line or a client-side tool — never upload. For one-off, small payloads where you want visual confirmation, an online tool is faster; the /convert/yaml page on this site runs entirely in your browser and the data never leaves your machine. For anything batch, reproducible or CI-bound, use the command line and write the validation steps into the same script.