YAML Config File Will Not Load: Common Errors in a Python GIS Pipeline
Problem statement
Lifting hard-coded paths and parameters into a YAML config is the step that turns a script into a pipeline. It is also the step where a run can fail before a single feature is read:
yaml.scanner.ScannerError: while scanning for the next token
found character '\t' that cannot start any token
in "configs/daily.yml", line 7, column 1
yaml.parser.ParserError: while parsing a block mapping
expected , but found ''
in "configs/daily.yml", line 12, column 3
And the quieter failures, which do not raise at all:
buffer: 100 msilently becomes the string"100 m", and arithmetic later failscrs: 4326is an int where the code expects"EPSG:4326"simplify: nobecomes the booleanFalseunder YAML 1.1 rules- a duplicated key silently overrides the earlier one
version: 2.10becomes the float2.1
Common causes:
- 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 a leading% - Windows paths where
\tor\nbecomes an escape sequence inside double quotes 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 β YAML errors are precise about location
- 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
from pathlib import Path
import sys
import yaml
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)
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
Step-by-step solution
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.
# broken
input_dir:data/raw # no space β the whole thing is one string
output_dir: data/out
buffer_m: 100 # three spaces where siblings use two
# 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/city.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.
Quote values that contain YAML syntax
A colon followed by a space, a #, or a leading special character changes the meaning of a scalar.
# broken
title: Parcels: 2026 update # second colon starts a new mapping
note: cleaned # by hand # everything after # is a comment
db: postgresql://gis:secret@db/gis # the :// confuses the scanner in some contexts
win_path: C:\data\raw\tiles # backslashes are fine unquoted, not in double quotes
# correct
title: "Parcels: 2026 update"
note: "cleaned # by hand"
db: "postgresql://gis:secret@db/gis"
win_path: 'C:\data\raw\tiles' # single quotes: no escape processing
Single quotes are the safe choice for Windows paths and regular expressions, because YAML processes \t, \n and \\ escapes only inside double quotes.
Watch the implicit typing
PyYAML follows YAML 1.1 typing rules, which convert more things than people expect.
simplify: no # β False, not the string "no"
country: NO # β False as well (Norway's ISO code!)
version: 2.10 # β 2.1 as a float
zipcode: 01234 # β 668 in some loaders (octal) or "01234"
crs: 4326 # β int, but GeoPandas wants "EPSG:4326"
buffer: 100 m # β the string "100 m"; arithmetic fails later
empty: # β None
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
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.
from pathlib import Path
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):
problems.append(f"{key}: expected {types}, got {type(cfg[key]).__name__}")
unknown = set(cfg) - set(REQUIRED) - {"steps", "log_level"}
problems += [f"unknown key (typo?): {k}" for k in sorted(unknown)]
if problems:
raise ValueError(f"{config_path}:\n " + "\n ".join(problems))
return cfg
Flagging unknown keys is as valuable as checking required ones β it catches output_dr: typos that would otherwise leave the 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.
from pathlib import Path
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
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.
import yaml
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)
cfg = yaml.load(text, Loader=UniqueKeyLoader)
Code examples
Example 1: a config loader worth reusing
from pathlib import Path
import os, sys
import yaml
def load_config(path: str | Path) -> dict:
path = Path(path).expanduser().resolve()
if not path.is_file():
raise FileNotFoundError(f"config not found: {path} (cwd={Path.cwd()})")
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}{loc}: {getattr(exc, 'problem', exc)}") from exc
if not isinstance(cfg, dict):
raise ValueError(f"{path}: top level must be a mapping, got {type(cfg).__name__}")
cfg = resolve_paths(cfg, path)
return validate(normalise(cfg), path)
if __name__ == "__main__":
cfg = load_config(sys.argv[1] if len(sys.argv) > 1 else "configs/daily.yml")
for k, v in cfg.items():
print(f"{k:12} = {v!r}")
Example 2: a realistic pipeline config
# configs/daily.yml
input_dir: ../data/raw
output_dir: ../data/out
crs: "EPSG:27700"
buffer_m: 50
overwrite: false
log_level: INFO
steps:
- name: clean
drop_empty_geometries: true
fix_invalid: true
- name: clip
boundary: ../data/ref/city_boundary.gpkg
- name: export
formats: ["gpkg", "parquet"]
Every string that could be misread as a number, a boolean or a mapping is quoted; every path is relative to the config file.
Example 3: environment overrides for the scheduled run
import os
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
This keeps one config file in version control while letting the scheduler point at a different data root.
Example 4: schema validation with a library
For configs beyond a dozen keys, a declarative schema pays for itself.
from pathlib import Path
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"
try:
cfg = Config(**yaml.safe_load(Path("configs/daily.yml").read_text(encoding="utf-8")))
except ValidationError as exc:
raise SystemExit(f"config invalid:\n{exc}")
You get coercion, defaults, range checks and a readable error report in one place β and cfg.buffer_m is typed everywhere downstream.
Explanation
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.
Edge cases or notes
- 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 holds multiple documents;safe_loadreads the first only. 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, whereyes/noare plain strings. 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.