YAML Multi-line Strings: | vs > vs Quoted (Indentation Rules)
YAML offers five ways to spread a string over several lines, and they produce four different values. Choosing the wrong one is how a shell script reaches the runner as a single unrunnable line, how a certificate loses the newlines that made it parseable, and how a ConfigMap value gains a trailing newline that a checksum notices. This guide covers each form, the header characters that modify it, and exactly what a parser hands back.
In short
Use the literal block scalar (|) whenever the line breaks are part of the value — shell scripts, certificates, embedded config — and the folded scalar (>) only to wrap one long sentence, because folding replaces each single newline with a space. Both keep exactly one trailing newline; add - to strip it, + to keep every blank line. Quoted strings fold like > as well.
Literal | keeps the line breaks, folded > does not
A block scalar begins with a single indicator character on the same line as the key, and everything indented beneath it is the value. The literal indicator, |, keeps every line break inside the block exactly as written. The folded indicator, >, replaces each single line break with a space, so the whole block arrives as one long line. Nothing else separates the two styles: identical indentation rules, identical header modifiers, identical treatment of the final newline. The choice is only ever about what happens to the newlines.
That difference is invisible in a diff, which is what makes it dangerous. Two blocks can look the same in an editor, sit three lines apart in the same file, and produce values that behave completely differently once a shell, a template engine or a certificate parser gets hold of them. The parse below shows the whole of it: one string has a newline in the middle, the other has a space.
The rule that survives contact with real files is simple. Reach for | when the line structure is part of the value — shell scripts, PEM certificates, embedded nginx or ini config, licence text, SQL. Reach for > only for prose you are wrapping for the benefit of the editor, such as a long description field. If you cannot decide, you want |: a stray newline in a description is harmless, while a missing newline in a script is a broken build. When a file already exists and you are not sure what it produces, paste it into the YAML validator and read the parsed value rather than inferring it from the indentation.
literal: |
line one
line two
folded: >
line one
line two
# Parsed with the yaml package →
# {
# "literal": "line one\nline two\n",
# "folded": "line one line two\n"
# }The block scalar header: chomping and indentation indicators
After | or > you may add a chomping indicator, an indentation indicator, or both, in either order. Chomping controls the newlines at the end of the block, and the YAML 1.2.2 specification names three modes: strip, written -, which removes "all trailing newlines"; clip, the default with no character at all, which removes "all trailing newlines except the first"; and keep, written +, which retains every one of them.
Clip is the reason nearly every block scalar ends in exactly one newline even when you never typed a blank line, and the reason a value you expected to be s3cr3t-value is actually s3cr3t-value followed by a line feed. It also means extra blank lines at the bottom of a block are discarded unless you explicitly asked for +. Strip is what you want for tokens, single-line secrets, and anything a checksum or an equality test will look at.
The indentation indicator is a single digit from 1 to 9 stating how far the content is indented, counted from the indentation of the parent node. You need it in exactly one situation: when the first non-empty line of the block starts with its own leading spaces. Without an indicator the parser takes that first line as the block's indentation level, so the extra spaces silently disappear — or, more often, the parser decides the block is wider than the mapping around it and the file stops parsing. In the sample below, banner: |2 fixes a block whose first line is deliberately indented four spaces; drop the 2 and the same file fails with "All mapping items must start at the same column at line 3, column 1".
| Indicator | Newlines inside | Final newline | Use when |
|---|---|---|---|
| | (literal, clip) | Kept exactly | Exactly one | Scripts, certificates, embedded config files |
| |- (literal, strip) | Kept exactly | None | Tokens and secrets compared byte for byte |
| |+ (literal, keep) | Kept exactly | Every trailing blank line | Fixtures that must reproduce trailing whitespace |
| > (folded, clip) | Replaced by a space | Exactly one | Long prose in a description field |
| >- (folded, strip) | Replaced by a space | None | One long command or URL wrapped over several lines |
| |2 or >2 (indentation) | As for | or > | As for the chomping used | The first content line starts with its own spaces |
clip: |
a
b
strip: |-
a
b
keep: |+
a
b
banner: |2
four-space first line
two-space line
# Parsed →
# {
# "clip": "a\nb\n",
# "strip": "a\nb",
# "keep": "a\nb\n\n\n",
# "banner": " four-space first line\ntwo-space line\n"
# }What folding really does
Folding is not "join everything with spaces". A single line break between two non-empty lines becomes one space. A blank line becomes a real newline, and each further blank line adds another, so a folded block can still contain paragraph breaks. And the specification carves out an exception for indentation: "folding does not apply to line breaks surrounding text lines that contain leading white space". A line indented further than the block keeps its own break, and so does the line before it.
That exception is the one genuinely useful property of >. You can write a paragraph that wraps comfortably in the editor and still embed an indented example inside it without the example collapsing into the prose. It is also a trap: a stray extra space at the start of one line in an otherwise ordinary folded block silently introduces two newlines that nobody wrote, which is a common cause of mysterious blank lines in generated emails and release notes.
Both effects are visible below. The first block folds two lines into one, then turns the blank line into a newline. The second folds its prose but keeps the indented pair of lines and the breaks on either side of them, exactly as the specification requires.
prose: >
para one line a
para one line b
para two
sample: >
prose that folds
an indented line
a second indented line
prose again
# Parsed →
# {
# "prose": "para one line a para one line b\npara two\n",
# "sample": "prose that folds\n an indented line\n a second indented line\nprose again\n"
# }Quoted and plain scalars span lines too
Block scalars are not the only multi-line form. A flow scalar — plain, single-quoted or double-quoted — may run over several lines as long as the continuation lines are indented more than the key. Those breaks fold exactly like >: a single break becomes a space, a blank line becomes a newline. Leading and trailing whitespace on every continuation line is discarded, so no quoted scalar can preserve line structure. If you want to keep newlines, you want a block scalar.
The three flow styles differ only in escaping. Double quotes are the only YAML style that processes escape sequences: \n, \t, \\, \uXXXX and \xNN mean what they mean in JSON or C, which makes double quotes the right home for a value that needs a real tab or a character you would rather not paste. Single quotes have exactly one escape, two apostrophes for a literal apostrophe, and take everything else verbatim — which makes them the safest place for Windows paths, regular expressions and anything else full of backslashes. Plain, unquoted scalars have no escapes at all.
Double quotes have one more trick worth knowing. A backslash at the end of a line joins that line to the next with no space at all, which is the only way in YAML to break a long token — a base64 blob, a URL, a hash — across source lines without changing the value. Note in the output that the continuation line's own indentation is discarded along with the break, so joined comes back as one unbroken word.
double: "one
two"
para: "one
two"
joined: "abcdef\
ghijkl"
escapes: "tab:\there | newline:\n | e-acute:\u00e9"
single: 'it''s all literal: \t stays as backslash-t'
plain:
this is a plain scalar
that continues here
and a new paragraph
# Parsed →
# {
# "double": "one two",
# "para": "one\ntwo",
# "joined": "abcdefghijkl",
# "escapes": "tab:\there | newline:\n | e-acute:é",
# "single": "it's all literal: \\t stays as backslash-t",
# "plain": "this is a plain scalar that continues here\nand a new paragraph"
# }Where it bites: GitHub Actions and docker-compose
A workflow step's run value is a string that GitHub writes to a temporary script file and executes, so its line breaks are the script's line breaks. Write run: > and every command in the block is joined with spaces into one line: the first step below arrives as the single string echo "step one" echo "step two", which either runs the wrong thing or fails outright. Write run: | and the two commands stay two commands. This is the most common YAML mistake in CI configuration, and it is invisible until the job runs.
The folded style does have one correct use in the same file, and it is the third step: a single long command wrapped over several source lines for readability. Because there is only one command, joining the lines with spaces is exactly what you want, and >- strips the trailing newline so nothing odd is appended. Both patterns appear below with their parsed values, and both are ordinary YAML — nothing in the Actions workflow syntax changes how a block scalar is read.
docker-compose has the same split. A command written as >- becomes node server.js --port 3000 --verbose, a single argument string that Compose then splits like a shell, which is what you want for a long command line. The same block written with | keeps its newline on the end, and an environment value written with > joins its lines and still keeps a trailing newline — which is how a variable ends up with an invisible line feed that a downstream script trips over. When a Compose file has grown past the point where you can read it, running it through the YAML formatter or converting it with YAML to JSON shows the real values instead of the indentation.
jobs:
build:
steps:
- name: Folded run (breaks)
run: >
echo "step one"
echo "step two"
- name: Literal run (works)
run: |
echo "step one"
echo "step two"
- name: Folded for one long command (fine)
run: >-
docker build
--build-arg VERSION=1.4
-t acme/api:1.4 .
# Parsed →
# "run": "echo \"step one\" echo \"step two\"\n"
# "run": "echo \"step one\"\necho \"step two\"\n"
# "run": "docker build --build-arg VERSION=1.4 -t acme/api:1.4 ."Kubernetes ConfigMaps and the indentation rules
A ConfigMap's data values are strings, and embedding a whole configuration file in one is the canonical use of the literal block scalar. Indentation inside the block is preserved relative to the block's own level, so an nginx config nested two levels deep in the manifest still arrives with its original two-space indentation and its braces on their own lines. Nothing inside the block is YAML any more: braces, colons, hashes and quotes are all just characters.
That last point catches people. A line starting with # inside a block scalar is text, not a comment, so a commented-out setting inside an embedded config file survives exactly as written. Conversely you cannot annotate a block scalar from the inside; a note has to go on a line above the key. A completely blank first line inside a block is content too, and produces a leading newline in the value.
The indentation rules themselves are short. Content must be indented more than the key; any consistent amount of at least one space works, and the first non-empty line sets the level for the rest. Tabs are never allowed as indentation anywhere in YAML — the yaml package rejects them with "Tabs are not allowed as indentation" — although a tab inside a block scalar's content, after the indentation, is fine. And if the first content line needs its own leading spaces, you have to say so with an indentation indicator, as in the header section above.
The MOTD key below shows the other half of the story. Written with |- it is exactly no trailing newline here; written with a plain | it would carry a line feed, which changes its length, its hash and its base64 form. In a Secret rather than a ConfigMap that difference is decisive: s3cr3t-value encodes to czNjcjN0LXZhbHVl, while the same value with a trailing newline encodes to czNjcjN0LXZhbHVlCg==, and the service reading it sees a different credential.
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
nginx.conf: |
server {
listen 80;
location / {
proxy_pass http://api:3000;
}
}
MOTD: |-
no trailing newline here
# Parsed →
# "nginx.conf": "server {\n listen 80;\n location / {\n proxy_pass http://api:3000;\n }\n}\n"
# "MOTD": "no trailing newline here"Trailing newlines, round trips and reformatting
The trailing newline is where most of the surprise lives, because it is invisible in every editor and in most diffs. A token written with | is one byte longer than the same token written with |-, and that byte changes its length, its checksum and its base64 encoding. Anything that compares values — a Kubernetes Secret, an HMAC signature, an API key sent in a header, a fixture in a test — needs the strip form. Anything that is genuinely a file, and files conventionally end with a newline, wants the default clip.
Round trips are the second surprise. A parser hands back plain strings, so the style information is gone; when a tool serializes those strings again it picks a style from the content, not from what you originally wrote. The yaml package emits a literal block for any string containing a newline and a plain scalar for one without, which means a value written as a folded > block comes back as | after a round trip, with the folded spaces baked in permanently. That is not a bug, but it does mean an automated rewrite can quietly change how a file reads without changing what it means.
The practical rules are short. Prefer | almost always. Use >- for a single long command. Use |- for anything that must not gain a byte. Never use > for a script. And when a value's exact contents matter, do not read them off the indentation: run the file through the YAML parser or YAML to JSON and look at the string the parser actually produced.
import { parse, stringify } from "yaml";
const doc = parse(`
tokenA: |
s3cr3t-value
tokenB: |-
s3cr3t-value
`);
console.log(JSON.stringify(doc));
// {"tokenA":"s3cr3t-value\n","tokenB":"s3cr3t-value"}
console.log(doc.tokenA.length, doc.tokenB.length);
// 13 12
console.log(Buffer.from(doc.tokenA).toString("base64"));
// czNjcjN0LXZhbHVlCg==
console.log(Buffer.from(doc.tokenB).toString("base64"));
// czNjcjN0LXZhbHVl
// Re-serializing picks a style from the content, not from the original file.
console.log(stringify({ token: "abc123\n", exact: "abc123", multi: "one\ntwo\n" }));
// token: |
// abc123
// exact: abc123
// multi: |
// one
// twoSources
Frequently asked questions
Why did my GitHub Actions run: block turn into one line?
Because it was written with the folded indicator, run: >, which replaces every single line break with a space. Two commands on two lines become one line separated by a space, so the second command is read as arguments to the first. Change the > to a | and the line breaks are preserved. Use >- only when the block holds a single long command wrapped for readability.
How do I write a multi-line string with no trailing newline?
Add the strip chomping indicator: |- for a literal block or >- for a folded one. The default, with no indicator, is clip, which keeps exactly one trailing newline, and + keeps every trailing blank line. Stripping matters for tokens, secrets and any value that is hashed, signed or compared, because that newline is a real byte that changes the result.
What does the 2 in |2 mean?
It is an indentation indicator: it tells the parser that the block's content is indented two spaces from the parent node instead of letting the parser deduce that from the first line. You only need it when the first non-empty line of the block starts with its own leading spaces — ASCII art, an already-indented snippet, a diff. Any digit from 1 to 9 is allowed, and it combines with - or +.
Can I put a comment inside a block scalar?
No. Everything indented under | or > is content, so a line starting with # is literal text and appears in the value. That is exactly what you want when the block contains a config file with its own comments, but it means a note to your colleagues has to go on a line above the key, outside the block.
Do quoted strings work for multi-line values?
They span lines, but they fold: each single line break becomes a space and each blank line becomes one newline, exactly as with >. They also drop the leading and trailing whitespace of every continuation line. Use double quotes when you need escape sequences, single quotes when you need backslashes taken literally, and a block scalar whenever the line structure itself matters.
Paste a workflow, manifest or Compose file into the YAML Validator to see the exact string each block scalar produces, with line and column on any error.
Check a YAML fileKeep working
Related tools
YAML Validator
Full YAML 1.2 validation with the exact line and column of every error — catch the indent bug before your pipeline does.
YAML Formatter
Normalize any YAML to clean, consistent style — uniform indentation, canonical quoting, validation built in.
YAML to JSON
Full YAML 1.2 parsing — anchors resolved, types preserved, errors pinpointed — straight into clean JSON.