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 m silently becomes the string "100 m", and arithmetic later fails
  • crs: 4326 is an int where the code expects "EPSG:4326"
  • simplify: no becomes the boolean False under YAML 1.1 rules
  • a duplicated key silently overrides the earlier one
  • version: 2.10 becomes the float 2.1

Common causes:

  • 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 a leading %
  • Windows paths where \t or \n becomes 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:

  1. read the error's line and column β€” YAML errors are precise about location
  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
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

Triage table mapping YAML error messages to their causes and fixes.
Six YAML errors that account for nearly every failed config load.

Step-by-step solution

Annotated YAML block showing mappings, sequences, indentation and quoting rules.
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.

# 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.

Flow from raw file through parse, resolve, normalise, validate to a typed config object.
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.

Edge cases or notes

  • 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 holds multiple documents; safe_load reads the first only. 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 yes/no are 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. 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.