YAML Config File Will Not Load: Common Errors in a Python GIS Pipeline
On this page
Problem statement
found character '\t' that cannot start any token, mapping values are not allowed here, expected <block end>, but found '-': PyYAML's errors name the parser state, not the mistake, and a config that fails this way stops the run before a single feature is read. The config on this page drives a real job β clip Rutland's Land Registry parcels to Oakham and buffer the result β and every error below is what PyYAML 6.0.3 said about one small edit to it: a tab, a missing space after a colon, a three-space indent, a list item beside a key, an unquoted title containing ": ":
for name, text in BROKEN.items():
try:
yaml.safe_load(text)
print(f"{name:26} loaded")
except yaml.YAMLError as exc:
mark = exc.problem_mark
print(f"{name:26} {type(exc).__name__}: {exc.problem} (line {mark.line + 1}, column {mark.column + 1})")
a tab before buffer_m ScannerError: found character '\t' that cannot start any token (line 6, column 1)
list item beside a key ParserError: expected <block end>, but found '-' (line 12, column 3)
no space after a colon ScannerError: mapping values are not allowed here (line 3, column 11)
three-space indent ParserError: expected <block end>, but found '<block mapping start>' (line 6, column 4)
unquoted title with ': ' ScannerError: mapping values are not allowed here (line 17, column 15)
And the quieter failures, which do not raise at all:
quiet = yaml.safe_load(QUIET)
for key, value in quiet.items():
print(f"{key:9} {QUIET_SOURCE[key]!r:14} -> {value!r} ({type(value).__name__})")
simplify 'no' -> False (bool)
country 'NO' -> False (bool)
version '2.10' -> 2.1 (float)
zipcode '01234' -> 668 (int)
crs '27700' -> 27700 (int)
buffer '50 m' -> '50 m' (str)
empty '' -> None (NoneType)
The edits behind those messages:
- tabs used for indentation β YAML forbids them outright
- inconsistent indentation between sibling keys
- a missing space after a colon (
key:valueis one scalar, not a mapping) - unquoted values containing
:or# - Windows paths in double quotes, where
\tand\nbecome escape sequences yaml.load()used without a loader, or the file simply not being where the script looked
Quick answer
When a YAML config will not load, or loads wrongly:
- read the error's line and column β but look at the line before as well
- check for tabs:
grep -Pn "\t" configs/daily.yml - make sure every
key:has a space after the colon - quote anything containing
:,#, a leading zero, or a Windows path - load with
yaml.safe_load()and validate the result against a schema before use
path = Path("configs/daily.yml")
try:
cfg = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
mark = getattr(exc, "problem_mark", None)
where = f" at line {mark.line + 1}, column {mark.column + 1}" if mark else ""
sys.exit(f"invalid YAML in {path}{where}: {getattr(exc, 'problem', exc)}")
print(cfg)
{'input_dir': '../data/raw', 'output_dir': '../data/out', 'boundary': '../data/ref/oakham.gpkg', 'crs': 'EPSG:27700', 'buffer_m': 50, 'overwrite': False, 'log_level': 'INFO', 'steps': [{'name': 'clean', 'drop_empty_geometries': True}, {'name': 'clip', 'boundary': '../data/ref/oakham.gpkg'}, {'name': 'export', 'formats': ['gpkg', 'parquet']}]}
safe_load refuses to construct arbitrary Python objects, which is both a security property and a debugging one: it will not silently do something surprising with a tag it does not understand.
The errors and what they mean
Tabs, colons, quoting and implicit types
Rule out tabs first
YAML specifies spaces for indentation and rejects tabs with an error that mentions a character most editors do not show.
grep -Pn "\t" configs/daily.yml # any hit is a bug
sed -i 's/\t/ /g' configs/daily.yml # replace with two spaces
Add .editorconfig to the repository so the problem does not come back:
[*.{yml,yaml}]
indent_style = space
indent_size = 2
Check the colon-space and the indentation
Two rules cover most parse errors: a colon that introduces a value needs a space after it, and siblings must be indented identically. The missing space is reported on the next line, not the broken one: input_dir:data/raw is read as the start of a plain scalar that runs on, and only the colon on the following line makes the scanner give up β line 3 in the table above, for a mistake on line 2. The extra indent is reported on its own line, as a parser error rather than a scanner error.
# broken
input_dir:data/raw # no space: reported at line 2, column 11
output_dir: data/out
buffer_m: 100 # three spaces where siblings use none
# correct
input_dir: data/raw
output_dir: data/out
buffer_m: 100
A list is a sequence of - items indented under their key:
steps:
- name: clip
boundary: ../data/ref/oakham.gpkg
- name: buffer
distance_m: 50
Each item's keys line up with each other; name and boundary belong to the same item because they share indentation. Mixing a key and a list item at the same level is the expected <block end>, but found '-' error.
Quote values that contain YAML syntax
A colon followed by a space, or a space followed by #, changes the meaning of a scalar:
for text in ["title: Parcels: 2026 update", 'title: "Parcels: 2026 update"',
"note: cleaned # by hand", 'note: "cleaned # by hand"',
"db: postgresql://gis:secret@db/gis",
r"win_path: C:\data\raw\tiles", r'win_path: "C:\data\raw\tiles"',
r'win_path: "C:\temp\new"', r"win_path: 'C:\temp\new'"]:
try:
print(f"{text:36} -> {yaml.safe_load(text)!r}")
except yaml.YAMLError as exc:
print(f"{text:36} -> {type(exc).__name__}: {exc.problem}")
title: Parcels: 2026 update -> ScannerError: mapping values are not allowed here
title: "Parcels: 2026 update" -> {'title': 'Parcels: 2026 update'}
note: cleaned # by hand -> {'note': 'cleaned'}
note: "cleaned # by hand" -> {'note': 'cleaned # by hand'}
db: postgresql://gis:secret@db/gis -> {'db': 'postgresql://gis:secret@db/gis'}
win_path: C:\data\raw\tiles -> {'win_path': 'C:\\data\\raw\\tiles'}
win_path: "C:\data\raw\tiles" -> ScannerError: found unknown escape character 'd'
win_path: "C:\temp\new" -> {'win_path': 'C:\temp\new'}
win_path: 'C:\temp\new' -> {'win_path': 'C:\\temp\\new'}
The URL is fine unquoted β its colons are not followed by spaces. The Windows paths are the trap: unquoted or in single quotes they are read literally; in double quotes \d is rejected as an unknown escape, which is at least loud, but "C:\temp\new" loads without complaint as a path containing a tab and a newline. Single quotes are the safe choice for Windows paths and regular expressions, because YAML processes escapes only inside double quotes.
Watch the implicit typing
PyYAML follows YAML 1.1 typing rules, which convert more things than people expect β the quiet failures listed at the top. ruamel.yaml implements YAML 1.2 and disagrees on several of them:
from ruamel.yaml import YAML
yaml12 = YAML(typ="safe").load(QUIET)
for key in ("simplify", "country", "zipcode", "version", "crs", "buffer"):
print(f"{key:9} PyYAML (1.1): {quiet[key]!r:7} ruamel.yaml (1.2): {yaml12[key]!r}")
simplify PyYAML (1.1): False ruamel.yaml (1.2): 'no'
country PyYAML (1.1): False ruamel.yaml (1.2): 'NO'
zipcode PyYAML (1.1): 668 ruamel.yaml (1.2): 1234
version PyYAML (1.1): 2.1 ruamel.yaml (1.2): 2.1
crs PyYAML (1.1): 27700 ruamel.yaml (1.2): 27700
buffer PyYAML (1.1): '50 m' ruamel.yaml (1.2): '50 m'
Norway's ISO code NO is False to PyYAML and the string 'NO' to ruamel; 01234 is octal 668 to one and 1234 to the other; 2.10 is the float 2.1 to both. Quote anything that must stay a string, and normalise types after loading:
def normalise(cfg: dict) -> dict:
crs = cfg.get("crs")
if isinstance(crs, int):
cfg["crs"] = f"EPSG:{crs}"
cfg["buffer_m"] = float(cfg.get("buffer_m", 0))
return cfg
print(normalise({"crs": 27700, "buffer_m": 50}))
{'crs': 'EPSG:27700', 'buffer_m': 50.0}
Validate the loaded config, not just the syntax
A file that parses is not a file that is correct. Check required keys, types and paths up front so the pipeline fails in the first second rather than after an hour.
REQUIRED = {"input_dir": str, "output_dir": str, "crs": (str, int), "buffer_m": (int, float)}
def validate(cfg: dict, config_path: Path) -> dict:
problems = []
for key, types in REQUIRED.items():
if key not in cfg:
problems.append(f"missing key: {key}")
elif not isinstance(cfg[key], types + (Path,) if isinstance(types, tuple) else (types, Path)):
problems.append(f"{key}: expected {types}, got {type(cfg[key]).__name__}")
unknown = set(cfg) - set(REQUIRED) - {"steps", "log_level", "overwrite", "boundary"}
problems += [f"unknown key (typo?): {k}" for k in sorted(unknown)]
if problems:
raise ValueError(f"{config_path.name}:\n " + "\n ".join(problems))
return cfg
typo = yaml.safe_load(Path("configs/daily.yml").read_text().replace("output_dir:", "output_dr:").replace("buffer_m: 50", "buffer_m: 50 m"))
try:
validate(typo, Path("configs/daily.yml"))
except ValueError as exc:
print(exc)
daily.yml:
missing key: output_dir
buffer_m: expected (<class 'int'>, <class 'float'>), got str
unknown key (typo?): output_dr
Flagging unknown keys is as valuable as checking required ones β it catches output_dr: typos that would otherwise leave a default silently in place.
Resolve paths relative to the config file
Users expect a path in a config to be relative to that config, not to the working directory of whoever ran the job.
PATH_KEYS = ("input_dir", "output_dir", "boundary")
def resolve_paths(cfg: dict, config_path: Path, keys=PATH_KEYS) -> dict:
root = config_path.resolve().parent
for key in keys:
if cfg.get(key):
cfg[key] = (root / Path(cfg[key]).expanduser()).resolve()
return cfg
resolved = resolve_paths(dict(cfg), Path("configs/daily.yml"))
print({k: resolved[k].relative_to(Path.cwd()).as_posix() for k in ("input_dir", "output_dir")})
print((Path("configs") / Path("/srv/gis/raw")).as_posix())
{'input_dir': 'data/raw', 'output_dir': 'data/out'}
/srv/gis/raw
Joining an absolute path onto root returns the absolute path unchanged, so both styles work.
Beware duplicate keys
PyYAML accepts a duplicated key and keeps the last value, silently. In a long config this is very easy to miss.
class UniqueKeyLoader(yaml.SafeLoader):
def construct_mapping(self, node, deep=False):
seen = set()
for key_node, _ in node.value:
key = self.construct_object(key_node, deep=deep)
if key in seen:
raise ValueError(f"duplicate key {key!r} at line {key_node.start_mark.line + 1}")
seen.add(key)
return super().construct_mapping(node, deep)
doubled = "buffer_m: 50\ncrs: 'EPSG:27700'\nbuffer_m: 100\n"
print("safe_load:", yaml.safe_load(doubled))
try:
yaml.load(doubled, Loader=UniqueKeyLoader)
except ValueError as exc:
print("UniqueKeyLoader:", exc)
safe_load: {'buffer_m': 100, 'crs': 'EPSG:27700'}
UniqueKeyLoader: duplicate key 'buffer_m' at line 3
A loader and config that fail clearly
The data used on this page
No data files are needed to load a config; the one on this page describes a real job over HM Land Registry's INSPIRE parcels for Rutland, cut to Oakham, which the pipeline anatomy guide runs. The setup writes the config and the broken variants.
import os
import sys
from pathlib import Path
import yaml
Path("configs").mkdir(exist_ok=True)
Path("configs/daily.yml").write_text("""\
# configs/daily.yml
input_dir: ../data/raw
output_dir: ../data/out
boundary: ../data/ref/oakham.gpkg
crs: "EPSG:27700"
buffer_m: 50
overwrite: false
log_level: INFO
steps:
- name: clean
drop_empty_geometries: true
- name: clip
boundary: ../data/ref/oakham.gpkg
- name: export
formats: ["gpkg", "parquet"]
""", encoding="utf-8")
GOOD = Path("configs/daily.yml").read_text()
BROKEN = {
"a tab before buffer_m": GOOD.replace("buffer_m: 50", "\tbuffer_m: 50"),
"list item beside a key": GOOD.replace(" - name: clean", " name: clean\n - name: tidy"),
"no space after a colon": GOOD.replace("input_dir: ../data/raw", "input_dir:../data/raw"),
"three-space indent": GOOD.replace("buffer_m: 50", " buffer_m: 50"),
"unquoted title with ': '": GOOD + "title: Parcels: 2026 update\n",
}
QUIET_SOURCE = {"simplify": "no", "country": "NO", "version": "2.10", "zipcode": "01234",
"crs": "27700", "buffer": "50 m", "empty": ""}
QUIET = "".join(f"{k}: {v}\n" for k, v in QUIET_SOURCE.items())
print(f"config: {len(GOOD.splitlines())} lines; {len(BROKEN)} broken variants; PyYAML {yaml.__version__}")
config: 16 lines; 5 broken variants; PyYAML 6.0.3
Example 1: a config loader worth reusing
def load_config(path: str | Path) -> dict:
path = Path(path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"config not found: {path.name} (looked in {path.parent.name}/)")
try:
cfg = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
except yaml.YAMLError as exc:
mark = getattr(exc, "problem_mark", None)
loc = f" line {mark.line + 1} column {mark.column + 1}" if mark else ""
raise ValueError(f"invalid YAML in {path.name}{loc}: {getattr(exc, 'problem', exc)}") from exc
if not isinstance(cfg, dict):
raise ValueError(f"{path.name}: top level must be a mapping, got {type(cfg).__name__}")
cfg = resolve_paths(cfg, path)
return validate(normalise(cfg), path)
for name in ("daily.yml", "nightly.yml", "broken.yml"):
if name == "broken.yml":
Path("configs/broken.yml").write_text(BROKEN["a tab before buffer_m"])
try:
loaded = load_config(f"configs/{name}")
print(name, "->", {k: loaded[k] for k in ("crs", "buffer_m", "log_level")})
except (FileNotFoundError, ValueError) as exc:
print(name, "->", exc)
daily.yml -> {'crs': 'EPSG:27700', 'buffer_m': 50.0, 'log_level': 'INFO'}
nightly.yml -> config not found: nightly.yml (looked in configs/)
broken.yml -> invalid YAML in broken.yml line 6 column 1: found character '\t' that cannot start any token
Example 2: a realistic pipeline config
print(GOOD)
# configs/daily.yml
input_dir: ../data/raw
output_dir: ../data/out
boundary: ../data/ref/oakham.gpkg
crs: "EPSG:27700"
buffer_m: 50
overwrite: false
log_level: INFO
steps:
- name: clean
drop_empty_geometries: true
- name: clip
boundary: ../data/ref/oakham.gpkg
- name: export
formats: ["gpkg", "parquet"]
Every string that could be misread β the CRS, with its colon β is quoted; every path is relative to the config file.
Example 3: environment overrides for the scheduled run
def apply_env_overrides(cfg: dict, prefix: str = "GIS_") -> dict:
"""GIS_BUFFER_M=100 overrides cfg['buffer_m']."""
for env_key, raw in os.environ.items():
if not env_key.startswith(prefix):
continue
key = env_key[len(prefix):].lower()
if key in cfg:
current = cfg[key]
if isinstance(current, bool):
cfg[key] = raw.strip().lower() in {"1", "true", "yes", "on"}
elif isinstance(current, (int, float)):
cfg[key] = type(current)(raw)
else:
cfg[key] = raw
return cfg
os.environ.update({"GIS_BUFFER_M": "100", "GIS_OVERWRITE": "yes", "GIS_OUTPUT_DR": "typo ignored"})
over = apply_env_overrides(load_config("configs/daily.yml"))
print({k: over[k] for k in ("buffer_m", "overwrite")})
{'buffer_m': 100.0, 'overwrite': True}
This keeps one config file in version control while letting the scheduler change a parameter. An environment variable whose name matches no key is ignored β a typo there is as silent as a typo in the file, which is an argument for validating after the overrides too.
Example 4: schema validation with a library
For configs beyond a dozen keys, a declarative schema pays for itself.
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class Config(BaseModel):
input_dir: Path
output_dir: Path
crs: str = "EPSG:4326"
buffer_m: float = Field(0, ge=0)
overwrite: bool = False
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
print(Config(**yaml.safe_load(GOOD)).buffer_m)
bad = yaml.safe_load(GOOD.replace("buffer_m: 50", "buffer_m: -5").replace("log_level: INFO", "log_level: VERBOSE")
.replace('crs: "EPSG:27700"', "crs: 27700"))
try:
Config(**bad)
except ValidationError as exc:
print(f"{exc.error_count()} problems:")
for err in exc.errors():
print(f" {'.'.join(map(str, err['loc']))}: {err['msg']}")
50.0
3 problems:
crs: Input should be a valid string
buffer_m: Input should be greater than or equal to 0
log_level: Input should be 'DEBUG', 'INFO', 'WARNING' or 'ERROR'
You get coercion, defaults, range checks and a readable error report in one place β and cfg.buffer_m is typed everywhere downstream. Note what pydantic does not do by default: it rejects the integer 27700 for a str field rather than converting it, which is exactly the unquoted-CRS case normalise() handled by hand.
Structure from whitespace alone
YAML encodes structure in whitespace, which is what makes it pleasant to read and unforgiving to write. The parser has to decide, from indentation alone, whether a line is a sibling, a child, or a list item. A single extra space changes the shape of the document, and a tab is ambiguous enough that the specification bans it outright rather than guessing a width.
The second source of surprise is implicit typing. YAML infers a type from a scalar's shape: 100 is an int, 1.5 a float, true/yes/on booleans, null/~/empty are None, and everything else is a string. PyYAML implements the YAML 1.1 rules, where no and off are also booleans β which is how a country code of NO becomes False. Quoting is not decoration here; it is how you say "this is text".
Together these explain why config failures split into two families. Syntax errors are loud and precisely located: the exception carries a problem_mark with the line and column, and reading it is usually enough. Semantic errors are silent: the file parses, the value has the wrong type or a typo'd key falls back to a default, and the pipeline runs to completion with the wrong parameters. Those are the expensive ones, and only validation catches them.
That is why a config loader worth having does four things in order: parse, resolve paths relative to the file, normalise types, then validate against an explicit schema β failing with a message that names the file, the key, and what was expected. A pipeline that refuses to start on a bad config is far cheaper than one that buffers by 0 metres for a week because a key was misspelled.
Empty files and unsafe loaders
- An empty file loads as
None:yaml.safe_load("")returnsNone, not{}. Useor {}before touching keys. yaml.load()without a Loader is unsafe: It can construct arbitrary Python objects. Always usesafe_loadfor configuration.- Document separators: A file with
---markers between two blocks holds two documents, andsafe_loadrefuses it withexpected a single document in the stream. Usesafe_load_all()and iterate if you meant several. - Anchors and aliases work but confuse readers:
<<: *defaultsmerge keys are supported by PyYAML and are handy for shared blocks, but they make diffs harder to review. - YAML 1.2 vs 1.1:
ruamel.yamlimplements 1.2, wherenoandNOare plain strings and01234is 1234, as the comparison above shows. Switching libraries can change how an existing config parses. - Long strings: Use
|to keep newlines and>to fold them. A bare multi-line scalar is a parse error more often than not. - Comments are lost on round-trip:
yaml.safe_dump()of a loaded config drops every comment. Useruamel.yamlif the file must be rewritten by code and stay readable.
Internal links
- How to Drive a GIS Pipeline from a YAML Config File in Python
- How to Validate Pipeline Inputs and Outputs Automatically in Python
- How to Turn a GIS Script into a Command-Line Tool with argparse
- How to Chain GIS Processing Steps into a Reusable Pipeline in Python
- Relative Paths Break When a GIS Script Runs Automatically: How to Fix It
- How to Make a GIS Workflow Reproducible in Python
FAQ
What does "found character '\t' that cannot start any token" mean?
There is a literal tab character in the indentation. YAML requires spaces, so replace every tab with two spaces and configure your editor to insert spaces in .yml files.
Why did my value no become False?
PyYAML follows YAML 1.1, which treats yes, no, on and off as booleans. Quote the value β "no" β to keep it a string. This bites hardest on ISO country codes.
Why is my EPSG code an integer?
Because 4326 is a number in YAML. Write crs: "EPSG:4326" or coerce after loading with f"EPSG:{value}" when an int is supplied.
Should paths in the config be relative to the config file or the working directory?
Relative to the config file β that is what a reader assumes when editing it. Resolve them at load time with (config_path.parent / value).resolve().
How do I catch a typo'd key like output_dr?
Compare the loaded keys against your known schema and raise on anything unrecognised. Without that check the typo leaves the default in place and the run silently writes to the wrong folder.
Is yaml.safe_load enough, or should I use a schema library?
safe_load handles syntax; it says nothing about meaning. For a handful of keys a hand-written validator is fine. Past a dozen, pydantic gives you coercion, defaults, ranges and readable errors for far less code.
Why does my config load but the pipeline uses the wrong values?
Almost always a type surprise or a duplicate key. Print the parsed config with repr() on start-up β seeing '100 m' instead of 100.0 makes the cause obvious immediately.