YAML 2: Multiline Strings, Anchors/Aliases, Merge Keys, Multiple Documents and KYAML
Write scripts with |, long prose with >, and use |- for single-line values such as secrets, otherwise a \n is appended. The merge key (<<) is not in YAML 1.2: Compose, kubectl and kubeconform support it, GitHub Actions does not, and js-yaml 5 needs an option. We show multiple documents, the document boundary of anchors, unsafe tags, KYAML and a yamllint rule set for Bookstore with real output.
Version note: The examples in this post were run with yq 4.54.3, PyYAML 6.0.3, js-yaml 5.5.0, yaml 2.9.1, yamllint 1.38.0, actionlint 1.7.12, Docker Compose 5.1, kubectl 1.37 (against a local kind cluster) and kubeconform 0.8.0. The specification used is version 1.2.2.
Guess First
We add a two-line step to the release workflow of Bookstore, the online bookstore used as the shared example across this site's series. To keep the lines short, we used >:
# A step from Bookstore's release workflow
steps:
- name: Show build info
run: >
echo "Building Bookstore.Api"
echo "Commit: abc1234"
When GitHub Actions runs this step, how many commands reach the shell and what is printed? What would change if we had written | instead of >?
Block Scalars: Literal, Folded and Chomping
The answer shows up when we read the step's text as a string with yq and hand it to bash:
$ yq -o json '.steps[0].run' publish-step.yaml
"echo \"Building Bookstore.Api\" echo \"Commit: abc1234\"\n"
$ bash -c "$(yq '.steps[0].run' publish-step.yaml)"
Building Bookstore.Api echo Commit: abc1234
The two commands were joined into one line and bash ran a single echo; the second echo became part of the printed text. The reason is the type of block scalar. YAML has two ways of writing a multiline string: | (literal) keeps line breaks as they are, > (folded) turns line breaks into spaces. Folded has two exceptions: a blank line stays a line break, and the line breaks around more-indented lines are kept. For scripts the right choice is always |; > exists to split a long piece of prose over several lines in the file.
The trailing line break is controlled by the chomping indicator. The default (clip) leaves exactly one \n, - (strip) removes all of them, + (keep) keeps every trailing blank line:
literal: |
line one
line two
folded: >
line one
line two
new paragraph
indented line
strip: |-
no trailing newline
keep: |+
all trailing newlines
clip: |
exactly one trailing newline
$ yq -o json block-scalars.yaml
{
"literal": "line one\nline two\n",
"folded": "line one line two\nnew paragraph\n indented line\n",
"strip": "no trailing newline",
"keep": "all trailing newlines\n\n",
"clip": "exactly one trailing newline\n"
}
This difference hurts most with secrets. If you write Bookstore's database password into a Kubernetes Secret with |, a \n is appended to the password:
apiVersion: v1
kind: Secret
metadata:
name: api-db
stringData:
password: |
example-password-change-me
passwordStripped: |-
example-password-change-me
$ kubectl apply --dry-run=client -f secret.yaml -o json | jq -c '.stringData'
{"password":"example-password-change-me\n","passwordStripped":"example-password-change-me"}
When the application reads the password as an environment variable or a file, the trailing \n becomes part of the password and the database rejects the login, while the password you see in the log looks right. Don't use block scalars for single-line values; if you have to, write |-.
The third setting is the indentation indicator. The parser takes the block's indentation from the first non-empty line. If the first line is deliberately more indented (aligned text, for example), you must give the indentation explicitly with a digit; the digit says how many spaces the block is indented relative to its parent key:
# the first line of the note starts with spaces, so the indentation must be given
invoiceNote: |2
Institutional order: 25 copies
Discount: 10%
$ yq -o json -I0 indent-indicator.yaml
{"invoiceNote":" Institutional order: 25 copies\n Discount: 10%\n"}
$ sed 's/|2/|/' indent-indicator.yaml | yq -o json -I0 .
Error: bad file '-': go-yaml load error in parser (while parsing a block mapping) at L2.C1-L4.C5: did not find expected key
Without the digit, the parser took the indentation from the first line (six spaces), counted the four-space second line as outside the block, and the file became invalid.
Embedding Scripts and Config: run: | and ConfigMaps
The two places where literal blocks are used most are the run: field in GitHub Actions and Kubernetes ConfigMaps. In Bookstore's manifest file the production settings of Bookstore.Api sit inside a ConfigMap as they are; the Deployment mounts this file as /app/appsettings.Production.json:
$ yq 'select(.kind == "ConfigMap")' deploy/k8s/api.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
data:
appsettings.Production.json: |
{
"Catalog": {
"PageSize": 20,
"MaxPageSize": 100
},
"Shipping": {
"DefaultCountry": "NO"
}
}
$ kubectl apply -f deploy/k8s/api.yaml
configmap/api-config created
deployment.apps/api created
service/api created
$ kubectl get configmap api-config -o jsonpath='{.data.appsettings\.Production\.json}' | jq -c .
{"Catalog":{"PageSize":20,"MaxPageSize":100},"Shipping":{"DefaultCountry":"NO"}}
The indentation of the embedded JSON is independent of YAML's indentation: the parser removes the block's common indentation (four spaces) and what remains is the JSON's own indentation. The text we read back from the ConfigMap is valid JSON that jq parses without trouble. If the embedded config grows, keeping it in a separate file and generating the ConfigMap with Kustomize's configMapGenerator is easier to maintain; that is covered in the Kustomize post of the Kubernetes series.
Anchors, Aliases and Merge Keys
An anchor (&name) names a node, an alias (*name) reuses the same node elsewhere. A merge key (<<) copies the keys of one mapping into another and lets you override them. The retry settings of two HTTP clients in Bookstore (for merge.mjs below, run npm install in the folder first):
# The same retry policy for two HTTP clients
defaults: &retry
maxAttempts: 3
delayMs: 200
payments:
retry: *retry
shipping:
retry:
<<: *retry
delayMs: 500
$ yq --yaml-fix-merge-anchor-to-spec -o json -I0 'explode(.) | .shipping' anchors.yaml
{"retry":{"maxAttempts":3,"delayMs":500}}
$ python -c 'import yaml, json; print(json.dumps(yaml.safe_load(open("anchors.yaml"))["shipping"]))'
{"retry": {"maxAttempts": 3, "delayMs": 500}}
$ node merge.mjs anchors.yaml
js-yaml {"retry":{"<<":{"maxAttempts":3,"delayMs":200},"delayMs":500}}
js-yaml (1.1) {"retry":{"maxAttempts":3,"delayMs":500}}
yaml {"retry":{"<<":{"maxAttempts":3,"delayMs":200},"delayMs":500}}
yaml (merge) {"retry":{"maxAttempts":3,"delayMs":500}}
Anchors and aliases are part of the YAML 1.2 spec and every parser supports them. The merge key is not in the spec: it is a separate feature defined in the type list published for YAML 1.1 in 2005. So support depends on the library. PyYAML supports it. js-yaml 5, however, uses the YAML 1.2 core schema by default and leaves << as an ordinary key; to merge you have to pass YAML11_SCHEMA explicitly. The yaml package needs merge: true as well. yq merges, but 4.54.3 prints a warning on every run: without the --yaml-fix-merge-anchor-to-spec flag it applies a precedence order that differs from the spec when there are several merge sources. The help text says the flag will become the default in late 2025, but in this version it is still off.
Merge Keys in Compose, Kubernetes and GitHub Actions
The plan was to try "do the tools support it?" one by one. In Compose, top-level fields starting with x- are extension fields that Compose ignores; defining the anchor there is a common pattern:
name: bookstore
x-api-env: &api-env
ASPNETCORE_ENVIRONMENT: Development
Catalog__PageSize: "20"
services:
api:
image: bookstore-api:dev
environment:
<<: *api-env
Shipping__DefaultCountry: "NO"
worker:
image: bookstore-worker:dev
environment:
<<: *api-env
Catalog__PageSize: "50"
$ docker compose -f compose-merge/compose.yaml config | yq '.services[].environment'
ASPNETCORE_ENVIRONMENT: Development
Catalog__PageSize: "20"
Shipping__DefaultCountry: "NO"
ASPNETCORE_ENVIRONMENT: Development
Catalog__PageSize: "50"
Compose supports the merge key; the worker service took the shared settings and overrode Catalog__PageSize. Don't confuse this with Compose's rules for merging several files: that mechanism (override files, the !override and !reset tags) is in the multiple environments post of the Docker Compose series. The Kubernetes side:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
selector:
matchLabels: &labels
app.kubernetes.io/name: bookstore-api
template:
metadata:
labels:
<<: *labels
app.kubernetes.io/version: "1.10"
spec:
containers:
- name: api
image: bookstore-api:1.10.0
$ kubectl apply --dry-run=server -f deploy-merge.yaml -o json | jq -c '.spec.template.metadata.labels'
{"app.kubernetes.io/name":"bookstore-api","app.kubernetes.io/version":"1.10"}
$ kubeconform -summary deploy-merge.yaml
Summary: 1 resource found in 1 file - Valid: 1, Invalid: 0, Errors: 0, Skipped: 0
kubectl and kubeconform support the merge key too, because they convert YAML to JSON with a library that knows YAML 1.1 features. GitHub Actions is different:
name: merge-key
on: workflow_dispatch
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
env: &dotnet-env
DOTNET_NOLOGO: "true"
DOTNET_CLI_TELEMETRY_OPTOUT: "true"
steps:
- run: dotnet --info
publish:
runs-on: ubuntu-latest
env:
<<: *dotnet-env
CONFIGURATION: Release
steps:
- run: dotnet publish src/Bookstore.Api -c "$CONFIGURATION"
$ actionlint .github/workflows/merge.yml
.github/workflows/merge.yml:19:7: GitHub Actions does not support YAML merge key "<<" [syntax-check]
|
19 | <<: *dotnet-env
| ^~~
GitHub Actions has supported anchors and aliases since September 2025, but its documentation describes only those two; actionlint rejects the merge key outright. So in GitHub Actions you can only reuse a block as it is; you cannot say "the same, except for this". What to do instead (composite actions, reusable workflows) is in the GitHub Actions series' post on reducing repetition.
Multiple Documents, the Limits of Anchors, and Tags
--- starts a new YAML document within a file. That is what makes it possible to keep an application's ConfigMap, Deployment and Service in one file in Kubernetes manifests. yq processes each document separately:
$ yq -N '.kind' deploy/k8s/api.yaml
ConfigMap
Deployment
Service
$ yq 'select(.kind == "Service") | .spec.ports' deploy/k8s/api.yaml
- name: http
port: 80
targetPort: http
$ kubectl get -f deploy/k8s/api.yaml -o name
configmap/api-config
deployment.apps/api
service/api
An anchor's scope is a single document. According to the spec, an alias may only refer to an anchor defined earlier in the same document; across files there is no connection at all. But the tools don't enforce this rule equally strictly:
# the anchor is defined in the first document, the alias used in the second
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
labels: &labels
app.kubernetes.io/part-of: bookstore
---
apiVersion: v1
kind: Service
metadata:
name: api
labels: *labels
$ kubectl apply --dry-run=server -f cross-doc.yaml
configmap/api-config configured (server dry run)
error: error parsing cross-doc.yaml: error converting YAML to JSON: yaml: unknown anchor 'labels' referenced
$ python -c 'import yaml; list(yaml.safe_load_all(open("cross-doc.yaml")))' 2>&1 | tail -2
yaml.composer.ComposerError: found undefined alias 'labels'
in "cross-doc.yaml", line 13, column 11
$ yq -o json -I0 '.metadata.labels' cross-doc.yaml
{"app.kubernetes.io/part-of":"bookstore"}
{"app.kubernetes.io/part-of":"bookstore"}
kubectl applied the first document and stopped at the second; PyYAML failed too. yq, however, resolved the anchor across documents and carried on. Testing a file with yq and calling it "working" can turn into a half-finished deploy in kubectl: the first resource in the file was applied, the second was not.
Tags state a value's type explicitly. !!str switches implicit typing off and gives the same result in every parser; think of it as the long form of quoting. Some libraries recognize their own tags too, and that is where the danger starts. PyYAML's !!python/object/apply tag calls a Python function:
apiVersion: !!str 1.10
isbn: !!str 9780441013593
unsafe: !!python/object/apply:os.system ["echo this should not run"]
$ python -c 'import yaml; yaml.safe_load(open("tags.yaml"))' 2>&1 | tail -2
yaml.constructor.ConstructorError: could not determine a constructor for the tag 'tag:yaml.org,2002:python/object/apply:os.system'
in "tags.yaml", line 3, column 9
$ head -2 tags.yaml | python -c 'import yaml, sys; print(yaml.safe_load(sys.stdin))'
{'apiVersion': '1.10', 'isbn': '9780441013593'}
$ head -2 tags.yaml | yq -o json -I0 .
{"apiVersion":"1.10","isbn":"9780441013593"}
safe_load rejected the tag because it does not recognize it. Had we called yaml.load with UnsafeLoader, reading the YAML would have meant running a command. Always read YAML from outside in safe mode; deserialization attacks are covered in detail in the A08 post of the Web Security (OWASP Top 10) series. In .NET, YamlDotNet also maps tags only to types you have registered.
KYAML: Kubernetes' Strict YAML
KYAML is Kubernetes' answer to the problems from the previous post: every string is double-quoted, structures are in flow style ({ } and [ ]), and indentation carries no meaning. The result is still valid YAML, so every parser can read it. KYAML arrived as alpha in 1.34, was enabled by default in 1.35 and became stable in 1.37; in kubectl you get it with -o kyaml:
$ kubectl create configmap shipping --from-literal=DefaultCountry=NO --from-literal=ApiVersion=1.10 --dry-run=client -o yaml
apiVersion: v1
data:
ApiVersion: "1.10"
DefaultCountry: "NO"
kind: ConfigMap
metadata:
name: shipping
$ kubectl create configmap shipping --from-literal=DefaultCountry=NO --from-literal=ApiVersion=1.10 --dry-run=client -o kyaml
---
{
kind: "ConfigMap",
apiVersion: "v1",
metadata: {
name: "shipping",
},
data: {
ApiVersion: "1.10",
DefaultCountry: "NO",
},
}
The normal YAML output quotes "NO" and "1.10" as well, because kubectl writes values it knows are strings safely. KYAML's difference is that the rule is single and fixed: if it is a string, it is quoted, full stop. yq can write KYAML too, but there is a detail here:
$ yq -o kyaml ../02-yaml/shipping.yaml | tee shipping.kyaml
# Bookstore shipping settings
{
shipping: {
countries: [
"TR",
"DE",
"NO",
],
cutoff: "12:30",
express: "yes",
giftWrap: "on",
labelFileMode: 0755,
maxWeightGrams: 1e3,
apiVersion: 1.10,
note: null,
},
}
$ python ../02-yaml/parse.py shipping.kyaml
PyYAML 6.0.3 {"shipping": {"countries": ["TR", "DE", "NO"], "cutoff": "12:30", "express": "yes", "giftWrap": "on", "labelFileMode": 493, "maxWeightGrams": "1e3", "apiVersion": 1.1, "note": null}}
ruamel.yaml {"shipping": {"countries": ["TR", "DE", "NO"], "cutoff": "12:30", "express": "yes", "giftWrap": "on", "labelFileMode": 755, "maxWeightGrams": 1000.0, "apiVersion": 1.1, "note": null}}
yq quoted the strings, so PyYAML now reads Norway correctly. But it left 0755, 1e3 and 1.10 exactly as they were written in the file, and the two parsers again gave different results. KYAML is a safe way of writing a value you already hold with the correct type (like the object kubectl gets from the API); it does not fix a file written with the wrong types by itself. You don't need to convert your manifests to KYAML; as the 1.33–1.37 changes post of the Kubernetes series also explains, its main use is inspecting output.
A Rule Set and CI with yamllint
A linter can catch most of the pitfalls so far. yamllint is a linter written in Python whose rules are defined in a YAML file. Its default rule set reports document-start (every file should start with ---) and a truthy warning for on: on Bookstore's files. The rule set we configured for Bookstore:
extends: default
rules:
# Kubernetes and Compose files rarely start with ---
document-start: disable
# GitHub Actions' "on:" key is fine; values must be true/false
truthy:
allowed-values: ["true", "false"]
check-keys: false
level: error
# 0755 is 493 in YAML 1.1 and 755 in YAML 1.2
octal-values:
forbid-implicit-octal: true
line-length:
max: 120
anchors:
forbid-unused-anchors: true
$ yamllint . && echo "bookstore: no findings"
bookstore: no findings
$ yamllint ../labs/02-yaml/shipping.yaml ../labs/02-yaml/compose.yaml ../labs/03-yaml/cross-doc.yaml
../labs/02-yaml/shipping.yaml
3:23 error truthy value should be one of [false, true] (truthy)
5:12 error truthy value should be one of [false, true] (truthy)
6:13 error truthy value should be one of [false, true] (truthy)
7:22 error forbidden implicit octal value "0755" (octal-values)
../labs/02-yaml/compose.yaml
8:33 error truthy value should be one of [false, true] (truthy)
9:26 error truthy value should be one of [false, true] (truthy)
11:36 error forbidden implicit octal value "0755" (octal-values)
../labs/03-yaml/cross-doc.yaml
6:11 error found unused anchor "labels" (anchors)
13:11 error found undeclared alias "labels" (anchors)
Bookstore's own files are clean; in the unquoted files from the previous post, Norway, yes, on and 0755 were caught, and so was the cross-document anchor. Know what yamllint does not catch, too: it has no rule that directly catches values like 1.10 and 12:30. Only the tool that will read the YAML (kubeconform, docker compose config) or schema validation catches those; we will see schemas in the fifth post of this series. In CI, the -f github output turns each finding into an annotation on the relevant line of the pull request:
$ yamllint -f github ../labs/02-yaml/compose.yaml
::group::../labs/02-yaml/compose.yaml
::error file=../labs/02-yaml/compose.yaml,line=8,col=33::8:33 [truthy] truthy value should be one of [false, true]
::error file=../labs/02-yaml/compose.yaml,line=9,col=26::9:26 [truthy] truthy value should be one of [false, true]
::error file=../labs/02-yaml/compose.yaml,line=11,col=36::11:36 [octal-values] forbidden implicit octal value "0755"
::endgroup::
name: ci
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.14"
- run: pip install yamllint==1.38.0
# -f github turns every finding into an annotation on the pull request
- run: yamllint -f github .
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v6
with:
dotnet-version: 10.0.x
- name: Build
run: |
dotnet restore src/Bookstore.Api
dotnet build src/Bookstore.Api -c Release --no-restore
$ actionlint .github/workflows/ci.yml && echo "actionlint: no findings"
actionlint: no findings
$ kubectl delete -f deploy/k8s/api.yaml
configmap "api-config" deleted from default namespace
deployment.apps "api" deleted from default namespace
service "api" deleted from default namespace
On the editor side, Red Hat's YAML extension for Visual Studio Code and Prettier can format YAML files on save; I did not run either of them for this post. A formatter fixes indentation and spacing but does not change types: an unquoted NO stays unquoted after formatting. So the formatter and the linter do different jobs, and you need both.
In Production
Anchors and merge keys make a file shorter but harder to read: to understand where a value comes from you have to move up and down the file. Our rule in Bookstore: anchors only in places explicitly named as "shared settings", like the x- blocks in Compose. In Kubernetes, Kustomize or Helm, and in GitHub Actions, composite actions and reusable workflows are clearer tools for reducing repetition.
In YAML from outside, aliases are also a memory risk: a small file written with nested aliases can expand into a very large structure when loaded. That is why js-yaml 5 introduced limits such as maxTotalMergeKeys. If you accept YAML from users, use a safe loader, a size limit and, where possible, schema validation together.
Finally, in a CI pipeline always validate YAML with the very tool that will read it in production: docker compose config for Compose, kubectl apply --dry-run=server or kubeconform for Kubernetes, actionlint for workflows. Saying "it can be read" with yq is not enough, as the cross-document anchor example showed.
Common Pitfalls
Writing a script with > is the mistake from the guess, and since it raises no error it is hard to notice; some commands become arguments of another command. The opposite happens too: if you indent a single line further in a description written with >, the line breaks around that line are kept and the text does not become one paragraph as you expected.
Using | for secrets and single-line values and forgetting the trailing \n; assuming an anchor also works in another file or another document; writing a merge key in GitHub Actions and the workflow never starting. And copy and paste: when you move a block with anchors to another file, the aliases become undefined and only the tool reading the file tells you so.
A Small Experiment
★☆☆ Write | instead of > in publish-step.yaml and run the two commands again. Then change | to |-; what changed in the yq output?
★★☆ Add a worker service to Bookstore's compose.yaml and share its common environment variables with api through an x- block and a merge key. Compare the final form of the two services with docker compose config.
★★★ Convert deploy/k8s/api.yaml to KYAML with kubectl apply --dry-run=client -o kyaml, read the result with PyYAML and ruamel.yaml and verify that the two parsers agree. Try the same with yq's -o kyaml output; where is the difference?
We have covered YAML in two posts. In the next post we look at the rest of the configuration formats: TOML, INI and .env files; we write the same Bookstore.Api setting in every format, read it with .NET's configuration providers and compare which format to pick when in a table.