ci-cdyamlgithub-actionsdocker-composejson

JSON vs. YAML for CI/CD Config — GitHub Actions, Docker Compose, and Beyond

·8 min read·Advanced

An underused fact: YAML is (almost) a superset of JSON

Every mainstream CI/CD tool — GitHub Actions, GitLab CI, CircleCI, Docker Compose — documents its config format as YAML. What's less advertised is that YAML 1.1/1.2's grammar is designed to be a near-superset of JSON, so a syntactically valid JSON document is, in almost every practical case, also syntactically valid YAML. That means you can write a GitHub Actions workflow, a Docker Compose file, or most other "YAML config" as plain JSON and it will parse and run identically — the tool never actually required YAML's extra syntax, only YAML's *parser*, which happens to also accept JSON.

json
{
  "version": "3.9",
  "services": {
    "web": {
      "image": "nginx:latest",
      "ports": ["8080:80"]
    }
  }
}

Save that as docker-compose.json... except Compose specifically looks for docker-compose.yml/.yaml by filename convention, so in practice you'd need -f docker-compose.json on the command line — the *parser* accepts JSON, but the *filename convention* still assumes YAML. This gap between "the format is accepted" and "the tooling expects it by default" is the crux of when JSON is actually usable here.

Where JSON genuinely wins

Generated or templated pipelines. If a script is *producing* the CI config — say, generating a matrix of jobs from a list of services in a monorepo — emitting JSON from code is simpler and less error-prone than emitting YAML, since JSON has one unambiguous way to represent a string, a list, or a nested object, while YAML has several (block vs. flow style, multiple ways to quote a string, indentation-sensitive nesting that a naive string-templating script can easily corrupt).

javascript
// Generating a GitHub Actions matrix programmatically — trivial in JSON,
// error-prone if you're hand-string-templating YAML's indentation rules
const jobs = services.map((s) => ({ service: s.name, dockerfile: s.path }));
console.log(JSON.stringify({ matrix: { include: jobs } }));

Programmatic editing after the fact. A tool that reads an existing config, modifies one field, and writes it back has a much easier time with JSON — parse, mutate the object, JSON.stringify — than with YAML, where a naive round-trip can silently reformat comments, anchors, or the author's original block/flow style choices even when no field values actually changed. (YAML-preserving editors exist, but they're a meaningfully bigger dependency than JSON.parse/JSON.stringify.)

Strict, unambiguous parsing. YAML has had real security and correctness footguns from its own flexibility — the Norway problem (an unquoted no parses as boolean false in YAML 1.1, not the country code), implicit type coercion surprises, and tab-vs-space indentation errors that fail silently in some parsers. JSON's much smaller grammar has none of these ambiguities.

Where YAML still wins for CI/CD specifically

Comments. JSON has none; a CI pipeline that a human hand-edits and needs to leave notes in (# temporarily skip this job, remove after MYAPP-4521 is fixed) is far more usable in YAML.

Anchors and aliases — YAML's &anchor/*alias/<<: *merge let you define a block once and reuse it across multiple jobs, which is common in larger CI configs to avoid repeating the same steps: block in every job:

yaml
.base_job: &base_job
  image: node:20
  before_script: [npm ci]

test:
  <<: *base_job
  script: [npm test]

build:
  <<: *base_job
  script: [npm run build]

JSON has no equivalent — every job's steps would need to be repeated in full, or you'd need to add your own templating layer on top (which is exactly what tools like GitHub Actions' reusable workflows, and GitLab's extends keyword, eventually grew into as YAML-native alternatives to raw anchors).

It's simply what every tool's docs, examples, and Stack Overflow answers use. Even where JSON would parse fine, deviating from YAML means every example you copy from documentation needs manual translation — a real, if unglamorous, cost.

A practical rule of thumb

SituationPrefer
Hand-written, hand-edited pipelineYAML — comments and anchors pay for themselves
Machine-generated pipeline (templated per-service, per-environment)JSON — safer to generate correctly
A config file another tool needs to programmatically patchJSON — safer to round-trip without formatting drift
Following the tool's own documented examples closelyYAML — matches what you'll find everywhere

Frequently asked questions

GitHub Actions' workflow files must be named with a .yml/.yaml extension and live in .github/workflows/, but because that file is parsed with a YAML parser, valid JSON content saved with a .yml extension will generally work — the constraint is the filename, not the actual byte content.

Yes, and unlike Docker Compose, kubectl apply -f is filename-agnostic about extension for many workflows — Kubernetes manifests are commonly written as pure JSON in GitOps pipelines that generate them programmatically (e.g., from Helm's --output json or a CDK8s program), specifically for the safer-round-trip reasons above.

In YAML 1.1, an unquoted NO, no, Norway-adjacent country-code-looking string can be interpreted as the boolean false rather than a string, because YAML 1.1's implicit typing rules treat several words (yes/no/on/off/true/false) as booleans. This has caused real bugs, e.g. an ISO country code "NO" for Norway silently becoming the boolean false in a config. YAML 1.2 narrowed this significantly, but not every parser has caught up, which is exactly the kind of ambiguity JSON's simpler grammar doesn't have.

No — a file has to commit to one syntax or the other; you can't use &anchor/*alias syntax inside a JSON-formatted document, since that syntax isn't part of JSON's grammar at all. If you need anchors, you need actual YAML syntax, not just a YAML-compatible JSON file.

Try JSONKit — JSON Developer Tools

Format, validate, convert and diff JSON. Instant, accurate and 100% private.