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:value is one scalar, not a mapping)
  • unquoted values containing : or #
  • Windows paths in double quotes, where \t and \n become 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:

  1. read the error's line and column β€” but look at the line before as well
  2. check for tabs: grep -Pn "\t" configs/daily.yml
  3. make sure every key: has a space after the colon
  4. quote anything containing : , #, a leading zero, or a Windows path
  5. 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

Six YAML errors and misreadings, with the fix for each. six ways a config goes wrongError or symptomFixfound character '\t'Indent with spaces, never tabsexpected <block end>Align siblings; no list itembeside a keymapping values are not allowedQuote ": ", or add the missingspace"C:\temp\new" loaded with a tabSingle-quote Windows pathsno and NO became FalseQuote it to keep it a stringA duplicate key silently wonLoad with a unique-key loaderThe first three raise; the last three load without a word, which is worse spatialworkflow.io
Six YAML errors that account for nearly every failed config load.

Tabs, colons, quoting and implicit types

An annotated YAML block: mappings, sequences, indentation and quoting. indentation is structureinput_dir: ../data/rawkey, colon, spacecrs: "EPSG:27700"quoted: colon insidesteps:a sequence follows- name: clipthe dash starts an itemboundary: city.gpkgsame indent, same itemTwo spaces make a child; a tab is a parse error spatialworkflow.io
Indentation is structure: two spaces make a child, a dash makes a list item.

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}
What PyYAML and ruamel.yaml made of the same unquoted values. one line of YAML, two librarieswrittenPyYAML 6.0.3 (1.1)ruamel.yaml 0.19 (1.2)simplify: noFalse'no'country: NOFalse'NO'zipcode: 01234668 (octal)1234version: 2.102.12.1crs: 2770027700 (int)27700 (int)buffer: 50 m'50 m' (str)'50 m' (str)Quote anything that must stay text; the libraries do not agree on the rest spatialworkflow.io
The implicit typing above, side by side.

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.

From raw file to a validated config: parse, resolve, normalise, validate. parsing is one of four stagesread fileutf-8safe_loadsyntax onlyresolve pathsrelative to itnormalisetypescrs, floatsvalidateschemarequired + unknownA file that parses can still be wrong in three more ways spatialworkflow.io
Parsing is one of four stages β€” a file that parses can still be wrong in three more ways.

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("") returns None, not {}. Use or {} before touching keys.
  • yaml.load() without a Loader is unsafe: It can construct arbitrary Python objects. Always use safe_load for configuration.
  • Document separators: A file with --- markers between two blocks holds two documents, and safe_load refuses it with expected a single document in the stream. Use safe_load_all() and iterate if you meant several.
  • Anchors and aliases work but confuse readers: <<: *defaults merge 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.yaml implements 1.2, where no and NO are plain strings and 01234 is 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. Use ruamel.yaml if the file must be rewritten by code and stay readable.

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.