Fixing YAML Errors That Make No Sense Step by Step
A YAML debugging guide: the error catalog across three parsers, a worked session through five stacked mistakes, silent failures and tools to prevent them.

YAML errors have a reputation. The messages talk about tokens, simple keys and block ends, and the position they report is often a line away from the mistake. Worse, the errors you see are the lucky ones, because the most damaging YAML problems raise nothing at all: a value silently changes type, a key is silently overwritten. This guide gives you a routine for both kinds. It starts with the catalog of messages and what each means, walks through a file with five stacked mistakes in the order they appear, and ends with the checks that catch what a parser does not.
How a parser fails, in two stages
Understanding the stage helps you read the message. The scanner turns characters into tokens and complains with "while scanning ...". The parser assembles tokens into a tree and complains with "while parsing ...". After that, a resolver gives each plain scalar a type, and it never complains: it guesses.
Three consequences follow, and they explain most of the confusion.
The parser stops at the first error. A file with five mistakes shows you one. You fix it, rerun and see the next. Do not conclude that your fix broke something; you have uncovered the next problem.
The position is where the parser gave up, not where you went wrong. For some errors the two coincide (a tab, a stray character). For others the parser notices a line later (a missing colon, a line indented one space too far). The rule is: look at the reported line, then the line above it.
The resolver can change your data without any error. A file that loads is not a file that is right.
The catalog
These are the messages from PyYAML, the most common source, with the equivalent from libyaml-based tools and Go. Each links to its own post.
| Message | Likely cause | Fix |
|---|---|---|
mapping values are not allowed here (libyaml and Go: in this context) | An unquoted colon-and-space in a value, or a line indented one space too far | Quote the value, align the keys (details) |
found character '\\t' that cannot start any token | A tab in indentation or after a colon | Replace with spaces (details) |
found character '@' ... (also ` ` and %`) | A value starts with a reserved character | Quote the value | ||
expected <block end>, but found '<block mapping start>' (libyaml and Go: did not find expected key) | A line indented between two levels | Align with an existing level (details) |
could not find expected ':' | A line of text with no colon inside a mapping | Add the colon, add a comment mark, or indent the continuation (details) |
found unexpected end of stream while scanning a quoted scalar | An opening quote never closed | Close the quote |
expected ',' or ']', but got ... | An unclosed or malformed flow list | Close the bracket |
could not determine a constructor for the tag '!...' | A value starting with ! | Quote it |
found unhashable key | Braces used as a key, typically {{ var }} | Quote the placeholder |
expected a single document in the stream | Several documents passed to safe_load | Use safe_load_all |
found unknown escape character | A backslash escape in double quotes that is not valid | Use single quotes or double the backslash |
Learn the first four by heart. They account for most of what you will meet.
The same error in different parsers
One mistake has several faces. The wrong-indentation case, a key at two spaces after a block at four:
CSafeLoader (libyaml): while parsing a block mapping | line 1, column 1 | did not find expected key | line 3, column 3
yq (Go) : go-yaml load error in parser (while parsing a block mapping) at L1.C1-L3.C3: did not find expected key
PyYAML (pure Python) : while parsing a block mapping | expected <block end>, but found '<block mapping start>' | line 3, column 3The line and column are identical, the wording differs, and that is why search results for these messages seem to describe different problems. When you search, add the position and a snippet of your structure, not the message alone. The same applies to "mapping values are not allowed here" (Python's pure loader) and "mapping values are not allowed in this context" (libyaml and Go).
A worked session: five mistakes, five steps
Here is a file in the style of a Compose configuration. It has five mistakes, and it is typical of what comes out of a few rushed edits.
services:
web:
image: nginx:1.25
ports:
- 80:80
- 22:22
environment:
MODE: prod
MSG: Starting: now
COUNTRY: NO
restart: always
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: s3cret #change me
volumes:
- data:/var/lib/postgresql
volumes:
data: {}
data: {}I loaded it, fixed the one error reported and loaded it again, five times. The messages, in order:
v1.yaml: mapping values are not allowed here (line 9, column 20)
v2.yaml: while parsing a block mapping expected <block end>, but found '<block mapping start>' (line 11, column 4)
v3.yaml: while scanning for the next token found character '\\t' that cannot start any token (line 17, column 1)
v4.yaml: parsedStep 1, line 9, column 20. MSG: Starting: now. The second colon is at column 20. The value contains a colon and a space. Fix: MSG: "Starting: now". This was a cause-one error, a colon inside an unquoted value.
Step 2, line 11, column 4. restart: always, three spaces where the siblings have four. The parser reports the line where the key sits between levels. Fix: four spaces.
Step 3, line 17, column 1. The volumes item starts with a tab. Invisible in the editor, exact in the message. Fix: spaces.
Step 4: the file parses. And here is the trap. The load succeeded and the result is wrong in four ways:
"ports": ["80:80", 1342]
"COUNTRY": false
"POSTGRES_PASSWORD": "s3cret"
"volumes": {"data": {}}The port 22:22 became the integer 1342 (base 60 in YAML 1.1). COUNTRY: NO became false. The password s3cret #change me lost everything after the space and hash, so the comment is a comment, which looks right until the day the password had a hash in it. And the duplicate data: {} collapsed into one entry, silently. yamllint found the last of these immediately:
v4.yaml
20:3 error duplication of key "data" in mapping (key-duplicates)Step 5. Remove the duplicate, quote "22:22" and "NO", and write the password with its comment as "s3cret" # change me. The final load:
"ports": ["80:80", "22:22"], "COUNTRY": "NO"and yamllint exits with status 0. Thirteen lines changed from the first version to the last. The lesson of the session: the first three errors were the easy ones, because the parser told you. The last four were the dangerous ones, because it did not. A routine that stops at "it parses" would have shipped them.
The routine
Use this order. It is cheapest-first, and each step catches what the previous one cannot.
Parse, and read the line and column. Then read the line above. Check for the four common causes: a tab, an unquoted special character, a misaligned line, a missing colon.
Fix one error and rerun. Resist fixing by pattern across the file until you understand the cause, since one mistake often repeats.
When it parses, print the structure. Dump the loaded data as JSON and read it. Look for values whose type surprises you, keys missing from where you expected them, lists that are null and strings that lost characters. This is the step that catches
1342andfalse.Lint.
yamllintcatches duplicates, truthy values, trailing spaces and indentation inconsistency that the parser accepted.Validate against a schema if the file has a defined shape. A misspelled key, a string where an integer belongs and a value outside an enum all parse, and all fail a schema.
Try a second parser. If PyYAML and
yqdisagree, you are looking at a YAML version difference (YAML 1.1 versus 1.2), and the value should be quoted.Test the real consumer. The tool that reads the file in production is the final judge.
Silent failures: a short field guide
The failures that raise nothing deserve their own list, because they are the ones that reach production.
Type changes. NO to False, 22:22 to 1342, 0755 to 493, 3.10 to 3.1, 1e3 to a string or a float depending on the version, 2026-01-05 to a date object. The cure is to quote, and to know which version your parser implements (why a number turns into something else in YAML, the Norway problem, version numbers turning into floats). On a CI workflow, the key on: itself became True in PyYAML, and the matrix entry 3.10 became 3.1 in both parsers.
Duplicate keys. PyYAML keeps the last, yq passes both through, ruamel.yaml raises. A whole nested block can vanish (YAML duplicate keys and the silent overwrite).
Truncation at a hash. A space followed by a hash starts a comment, so a value with a hash after a space loses the rest. Quote secrets and colors.
Invisible characters. Two non-breaking spaces as indentation loaded as a key named with those characters, with no error. A zero-width space after a colon turned a mapping line into a string (finding tab characters hiding in a YAML file).
Escapes in double quotes. "C:\temp\new" contains a tab and a newline (fixing special characters that break YAML values).
Null where a list was meant. A list with all items commented out is None, not [].
Comments vanishing when a program rewrites the file with a library that does not keep them.
For every one of these the detection is the same: print the structure, lint and validate.
Setting up so errors stay rare
A small amount of setup removes most of the above. Configure the editor for spaces and visible whitespace, with a YAML language server extension so errors appear as you type. Add yamllint with a project config to CI and a pre-commit hook; the config states the indentation width, relaxes line length to a warning and enables the truthy and key-duplicates rules (linting YAML with yamllint). Publish a JSON Schema for files with a fixed shape and validate with check-jsonschema: on a deliberately bad file it reported a misspelled key, a string where an integer belongs, a value outside the allowed set and a duplicate list entry, each with a path (validating config YAML with JSON Schema). Add a unit test that loads each config with the production parser and checks the types of the values the program reads. And when a program writes YAML, use a library, not string concatenation (writing YAML from Python without ugly output).
None of this is heavy. The linter and the schema check take milliseconds, and together they turn the five-step session above into one failed commit with all five findings in one list, apart from the type changes that only the schema and the structure printout reveal.
Telling your users what went wrong
If your program reads YAML that other people write, the raw exception is a poor message. A short wrapper turns the position into something a person can act on: the file name, the line and column, the offending line with a caret and a hint for the common causes. PyYAML's exceptions carry a problem, a problem_mark with the line and column (counted from zero), and often a context and context_mark, which is all you need:
import sys, yaml
def load_config(path):
text = open(path, encoding="utf-8").read()
try:
return yaml.safe_load(text)
except yaml.YAMLError as e:
mark = getattr(e, "problem_mark", None)
if mark is None:
raise
lines = text.splitlines()
line = lines[mark.line] if mark.line < len(lines) else ""
prev = lines[mark.line - 1] if mark.line > 0 else None
hint = ""
if "\t" in line:
hint = "The line contains a tab. YAML does not allow tabs for indentation."
elif "mapping values are not allowed" in e.problem:
hint = "A value may contain ': '. Quote it, or check this line's indentation."
elif "could not find expected ':'" in e.problem:
hint = "A line has text but no colon. Check the line above, too."
msg = [f"{path}: {e.problem} (line {mark.line + 1}, column {mark.column + 1})"]
if prev is not None: msg.append(f" {mark.line:4d} | {prev}")
msg.append(f" {mark.line + 1:4d} | {line}")
msg.append(" | " + " " * mark.column + "^")
if hint: msg.append(" hint: " + hint)
raise SystemExit("\n".join(msg))
if __name__ == "__main__":
print(load_config(sys.argv[1]))On the first and third versions of the file in the session above:
v1.yaml: mapping values are not allowed here (line 9, column 20)
8 | MODE: prod
9 | MSG: Starting: now
| ^
hint: A value may contain ': '. Quote it, or check this line's indentation.
v3.yaml: found character '\t' that cannot start any token (line 17, column 1)
16 | volumes:
17 | - data:/var/lib/postgresql
| ^
hint: The line contains a tab. YAML does not allow tabs for indentation.The user sees the line, the previous line for context, where the parser gave up and what to try. This costs thirty lines and removes a whole class of support questions. Three details matter. Print the previous line, because the mistake is often there. Convert the zero-based line to the one-based number editors show. And keep the hints short and few: a hint that is wrong half the time trains people to ignore it, so include only the ones you have seen to be right, tabs and unquoted colons being the best candidates.
For ruamel.yaml the exceptions carry similar marks under different names, and Go-based tools print L9.C20 style positions, so the same wrapper idea applies per library. If the application accepts several formats, normalize the position into one structure and format it once.
Reading the context lines
The longer messages have a shape worth decoding. They have up to four parts: a context (the construct the parser was inside), the position where that construct started, the problem and the position where it failed. In "while parsing a block mapping ... line 1, column 1 ... did not find expected key ... line 3, column 3", the parser was inside the block mapping that began at line 1, column 1, and failed at line 3, column 3. The first position tells you which level the misplaced line should have matched. In "while scanning a simple key ... could not find expected ':'", the context position is where the key started and the failure position is where the parser stopped looking, usually the next line.
When only one position is shown, as with "mapping values are not allowed here", the context is empty because the parser was not inside any construct that has a start worth reporting; the position is the colon. Learn to find the colon on the reported line at the reported column, and the cause is usually visible.
Errors from tools built on YAML
Most tools that read YAML use one of the libraries above and wrap their messages. Go-based tools commonly print something like error converting YAML to JSON: yaml: line 7: did not find expected key, where the number is a line and the rest is the go-yaml message you already know. Python tools print the PyYAML text with the file name. Tools that render templates first, such as chart renderers, print an error that names a line in the rendered output, which does not exist in your source file; render to a file and read that line.
When a tool says "unknown field", "expected string, got number" or "invalid value for key", the YAML parsed fine and the tool's own schema rejected the result. The fix is in the tool's documentation. The number-versus-string complaint is often a quoting problem: a version 3.10 or a code NO arrived as a number or a boolean. Quote it and rerun.
For multi-document files, remember that a parse error numbered line 41 is counted from the start of the file, not from the start of the document that contains it. Split the stream and test the pieces separately when the file is long.
Tests that catch what parsers miss
The silent failures need tests, not messages. A single test module per configuration file covers them. Load the file with the production parser. Assert the type of every value the program depends on: ports are integers, flags are booleans, versions and codes are strings. Assert that no required key is missing and that no key has the value None where one is not allowed. Run the strict duplicate-key loader on the file, and run yamllint from the test so a lint failure is a test failure.
Parametrize the test over a directory, so a new configuration file is covered the day it appears. When a bug is found in production, add the smallest file that reproduces it to a fixtures directory with a one-line comment about what went wrong. The collection becomes the team's memory of YAML mistakes, and a regression for each. Over a year it turns the five-step session above into a failed test with a clear name.
A case where Git made the error
Not every YAML error is typed by a person. Two branches each edited the same block of a small service file, one adding timeout: 30 at the end and the other timeout: 60 in the middle. Where both edits touched the same lines Git reported a conflict, as it should. Where they touched different lines, it merged cleanly:
Merge made by the 'ort' strategy.
app.yaml | 1 +
--- merged file
service:
name: web
timeout: 60
retries: 3
timeout: 30The merge succeeded, the file is valid YAML and the key timeout appears twice. PyYAML loads it with timeout equal to 30, ruamel.yaml raises, yamllint flags key-duplicates. Nobody typed this mistake and nobody reviewed it, because a clean merge shows no conflict markers. This is the strongest argument for linting after every merge and for a CI job on the main branch, not only on pull requests: the pull request of each branch was valid, and the combination was not.
Triage when many files fail
Inherited repositories have hundreds of YAML files and no linter. Do not fix them one by one. Lint everything at once with the machine-readable format and count by rule and by message, so you fix the most frequent cause first:
findings: 20 in 16 files
-- by rule
14 syntax
2 indentation
1 line-length
1 key-duplicates
1 hyphens
1 colons
-- syntax errors by message
4 mapping values are not allowed here
3 expected <block end>, but found '<block mapping start>'
2 found character '\t' that cannot start any token
1 found unexpected end of stream
1 found character '@' that cannot start any token
1 expected alphabetic or numeric character, but found '.'
1 expected ',' or ']', but got ':'
1 could not find expected ':'That was a run over the sample files used in this guide, and the pattern is typical: syntax errors dominate, and two messages cover half of them. Fix the unquoted colons across the repository first, with a search for lines that match a key followed by text containing a colon and a space, then the indentation, then the tabs, which a single sed handles. The counts also set the order of the linter rollout: start by failing the build on syntax errors only, which are objectively wrong, and enable style rules one at a time as the count for each reaches zero.
Keep the totals in the pull request that introduces the linter. A table showing 20 findings before and none after is the best argument for keeping the check, and it records which mistakes were common, which tells you what to teach.
Where the mistakes come from
Knowing how errors get in helps you prevent them. Four sources account for most of what I have seen in shared repositories.
Copy and paste from documentation. Documentation sites render YAML with their own fonts and often with smart quotes, non-breaking spaces and tabs converted by the page. A snippet pasted straight into a file can carry characters that look right and parse wrongly, or silently load as a different structure. Paste into a plain-text buffer, run the non-ASCII search, then insert.
Indentation lost in transit. Chat tools, issue trackers and email clients reflow text and strip leading spaces unless the snippet is in a code block. A config pasted from a ticket arrives flattened. If a pasted file fails with an indentation error and the content looks right, the whitespace is what changed.
Hand conversion from other formats. Turning JSON into YAML by deleting braces and quotes leaves commas, colons in strings and bare values behind. Use a converter, then quote what needs it (converting YAML to JSON with yq works in both directions).
Edits by tools that do not understand YAML. A search-and-replace across the repository, a find-and-replace in an editor and a sed script treat the file as text. They change a version in the middle of an indentation level, insert a line at the wrong depth or replace a key with a longer name that breaks alignment of a flow list. Prefer structure-aware edits (yq, a round-trip library) for anything that touches many files, and read the diff.
A fifth source is time. A file that has been valid for years acquires an error when a dependency updates its parser: a stricter version rejects what a looser one accepted, or a library moves to YAML 1.2 and stops reading yes as a boolean. When a previously working file fails after an upgrade, compare parser versions before comparing files.
A checklist to keep next to the editor
When a YAML file fails or misbehaves, run through these in order and stop at the first that explains it.
Is there a tab, or a non-ASCII space? Search for both.
Does the reported line have an unquoted colon followed by a space, or a hash after a space?
Does it start with
@, a backtick,%,!,*,&or a bracket?Does the reported line match the indentation of its siblings, counting characters rather than eyeballing?
Is a quote or bracket open on a previous line?
Does the line above have a colon where it needs one?
Does the file parse, but with surprising types? Print it as JSON and read every value.
Does it parse, but a key is missing? Check for duplicates and for a null list.
Do two parsers disagree? Quote the value they disagree about.
Is the YAML valid and the tool still unhappy? Read the tool's schema, not the YAML rules.
The checklist is short on purpose. Most files fail at the first three, and ninety seconds with the right tool is faster than reading the specification.
Teaching the team
The cheapest way to reduce YAML incidents is to make the failures familiar. Keep a page in the repository with the five or six errors your team has actually hit, each with the file that triggered it, the message and the fix. A new colleague who reads it once will recognize the next one on sight. Run a short exercise when someone joins: give them a broken file and the linter and let them fix it with the routine above.
Treat each production incident as a chance to improve the checks. If a quoted value was missed, add the rule to the linter config or the schema. If a merge produced a duplicate, add the post-merge lint job. If a tool read a version differently, add the second-parser comparison to the loader test. Over a few months the checks absorb the lessons, and the incidents become failures in CI that cost a minute each.
When you are stuck
Some failures do not yield to the routine. Reduce the file. Delete half, parse, and see whether the error remains; repeat on the half that has it. In a few minutes you have the smallest file that fails, and its cause is usually plain. Then try the same file with another parser, and with the linter, which sometimes gives a clearer message for the same fault. If the file is generated or templated, debug the rendered output and not the template.
If the error moves when you edit unrelated lines, an earlier construct is open: an unclosed quote, a bracket or a block scalar that swallows the lines after it. Search upward for the opening that has no closing.
If the file works on your machine and fails in production, compare the parsers and their versions first. Then compare the file: a different encoding, Windows line endings or a byte order mark can matter to a stricter tool. Last, compare the environment variables if anything substitutes them into the text.
Related reading
Each post in this cluster takes one failure: mapping values are not allowed here, found character that cannot start any token, did not find expected key, could not find expected colon, fixing indentation errors with a linter, numbers turning into something else, version numbers turning into floats, special characters, duplicate keys and hidden tabs. For the syntax underneath, read YAML syntax you need to stop guessing at.
References: the YAML 1.2.2 specification, libyaml, the yamllint rule reference and check-jsonschema. Print a file's parsed structure in the YAML to JSON tool.
When the error is not in the YAML
The file is a template. Files with Jinja or Go template syntax are not YAML until rendered. Lint the output.
The consumer rejects valid YAML. Kubernetes, Compose and CI systems apply their own schema on top. "Unknown field" or "expected string, got number" comes from them and is fixed against their documentation.
The encoding or line endings are wrong. A file saved in a different encoding or with a byte order mark can fail in some tools and not others.
The problem is a merge, not a typo. Two branches that each added a key can produce duplicates or misaligned blocks that look fine in each branch. Lint the merged result.
The numbers here differ from yours. The messages and values come from specific parser versions. Newer or different libraries word errors differently and resolve types by their own rules. Reproduce with your parser before trusting a table.


