Si trabajas con YAML en Python, casi con toda seguridad estás usando PyYAML. Es la biblioteca estándar, existe desde 2006, y viene con una función llamada yaml.load() que tiene una vulnerabilidad de seguridad crítica que ya ha quemado a muchos equipos. La solución es una sola palabra — safe_load — pero necesitas entender por qué, qué sacrificas, y cuándo la biblioteca más reciente ruamel.yaml es la mejor opción.

Esta guía cubre el parsing práctico de YAML en Python: carga segura, flujos multi-documento, serialización de objetos Python a YAML, patrones de archivos de configuración con valores por defecto, y manejo de errores. Todos los ejemplos usan escenarios del mundo real — sin datos de relleno.

Instalación

bash
pip install pyyaml

# For ruamel.yaml (covered later)
pip install ruamel.yaml

yaml.safe_load() — La que siempre deberías usar

Lo más importante que debes saber sobre PyYAML es que yaml.load() puede ejecutar código Python arbitrario embebido en un archivo YAML. Esto no es un riesgo teórico — es un vector de ataque bien documentado. Usa siempre yaml.safe_load():

python
import yaml

# DANGEROUS — never use this with untrusted input
data = yaml.load(open('config.yaml'), Loader=yaml.FullLoader)

# SAFE — use this for any YAML from external sources
data = yaml.safe_load(open('config.yaml'))

# The attack: a YAML file could contain this, which executes Python
# !!python/object/apply:os.system ["rm -rf /important-dir"]
Nota de seguridad: yaml.safe_load() solo admite tipos YAML estándar: cadenas, números, booleanos, null, listas y diccionarios. Lanzará un ConstructorError si el YAML contiene etiquetas específicas de Python como !!python/object. Ese es exactamente el comportamiento que quieres. yaml.full_load() es más segura que el antiguo yaml.load() sin argumentos, pero sigue siendo menos restrictiva que safe_load(). Empieza con safe_load() y solo cambia si realmente lo necesitas.

Cargar un archivo de configuración YAML

Aquí tienes un patrón realista de carga de configuración para una aplicación web. Cargamos un archivo de configuración YAML y usamos la fusión de diccionarios de Python para rellenar los valores no especificados con sus valores por defecto:

python
# config.yaml
database:
  host: postgres.internal
  port: 5432
  name: myapp_prod
  pool_size: 10

redis:
  host: redis.internal
  port: 6379

logging:
  level: INFO
  format: json
python
import yaml
from pathlib import Path
from typing import Any

DEFAULT_CONFIG = {
    'database': {
        'host': 'localhost',
        'port': 5432,
        'name': 'myapp',
        'pool_size': 5,
        'ssl': False,
    },
    'redis': {
        'host': 'localhost',
        'port': 6379,
        'db': 0,
    },
    'logging': {
        'level': 'DEBUG',
        'format': 'text',
    }
}

def deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge override into base, returning a new dict."""
    result = base.copy()
    for key, value in override.items():
        if key in result and isinstance(result[key], dict) and isinstance(value, dict):
            result[key] = deep_merge(result[key], value)
        else:
            result[key] = value
    return result

def load_config(config_path: str | Path) -> dict[str, Any]:
    path = Path(config_path)
    if not path.exists():
        raise FileNotFoundError(f"Config file not found: {path}")

    with path.open('r', encoding='utf-8') as f:
        raw = yaml.safe_load(f)

    if raw is None:
        return DEFAULT_CONFIG.copy()

    return deep_merge(DEFAULT_CONFIG, raw)


config = load_config('config.yaml')
print(config['database']['host'])       # postgres.internal
print(config['database']['ssl'])        # False  (from defaults)
print(config['redis']['db'])            # 0  (from defaults)

Serializar objetos Python a YAML

yaml.dump() serializa dicts, listas, cadenas, números, booleanos y None de Python a YAML. Por defecto usa el estilo flujo (llaves en línea) — establece default_flow_style=False para obtener el estilo de bloque más legible:

python
import yaml
from dataclasses import dataclass, asdict

@dataclass
class ServiceConfig:
    name: str
    replicas: int
    image: str
    port: int
    tags: list[str]

service = ServiceConfig(
    name='payment-api',
    replicas=3,
    image='payment-api:2.4.1',
    port=8080,
    tags=['payments', 'backend', 'critical']
)

# Convert dataclass to dict first, then dump to YAML
output = yaml.dump(
    asdict(service),
    default_flow_style=False,
    sort_keys=False,            # preserve insertion order
    allow_unicode=True
)
print(output)
# image: payment-api:2.4.1
# name: payment-api
# port: 8080
# replicas: 3
# tags:
# - payments
# - backend
# - critical

# Write to file
with open('service-config.yaml', 'w', encoding='utf-8') as f:
    yaml.dump(asdict(service), f, default_flow_style=False, sort_keys=False)

Flujos multi-documento con load_all

YAML admite múltiples documentos en un solo archivo, separados por ---. Esto es habitual en los manifiestos de Kubernetes, donde un único archivo puede contener un Deployment, un Service y un ConfigMap. Usa yaml.safe_load_all() para iterar sobre todos los documentos:

python
import yaml

# manifests.yaml contains multiple Kubernetes resources separated by ---
with open('manifests.yaml', 'r') as f:
    # safe_load_all returns a generator
    documents = list(yaml.safe_load_all(f))

for doc in documents:
    if doc is None:
        continue
    kind = doc.get('kind', 'Unknown')
    name = doc.get('metadata', {}).get('name', 'unnamed')
    print(f"{kind}: {name}")

# Deployment: payment-api
# Service: payment-api-svc
# ConfigMap: payment-api-config

También puedes escribir múltiples documentos en un flujo con yaml.dump_all():

python
import yaml

documents = [
    {'kind': 'Deployment', 'metadata': {'name': 'api'}, 'spec': {'replicas': 2}},
    {'kind': 'Service', 'metadata': {'name': 'api-svc'}, 'spec': {'port': 80}},
]

output = yaml.dump_all(documents, default_flow_style=False)
print(output)
# kind: Deployment
# metadata:
#   name: api
# spec:
#   replicas: 2
# ---
# kind: Service
# metadata:
#   name: api-svc
# spec:
#   port: 80

ruamel.yaml — Cuando necesitas preservar los comentarios

PyYAML tiene una limitación importante: elimina los comentarios al cargar. Si cargas un archivo YAML, lo modificas y lo vuelves a escribir, todos los comentarios desaparecen. Para archivos de configuración que mantienen humanos, perder los comentarios es inaceptable.

ruamel.yaml implementa un parser de ida y vuelta que preserva comentarios, el orden de las claves y el formato — apunta a la especificación YAML 1.2 por defecto. Es la opción correcta cuando editas programáticamente YAML que los humanos leerán después:

python
from ruamel.yaml import YAML

yaml = YAML()
yaml.preserve_quotes = True

# This config.yaml has important comments we need to keep:
# database:
#   host: localhost  # change this for production
#   port: 5432       # default PostgreSQL port
#   pool_size: 5     # increase under heavy load

with open('config.yaml', 'r') as f:
    config = yaml.load(f)

# Modify a value
config['database']['host'] = 'postgres.prod.internal'
config['database']['pool_size'] = 20

# Write back — comments and formatting are preserved!
with open('config.yaml', 'w') as f:
    yaml.dump(config, f)

# Result:
# database:
#   host: postgres.prod.internal  # change this for production
#   port: 5432                    # default PostgreSQL port
#   pool_size: 20                 # increase under heavy load
  • Usa PyYAML cuando leas YAML para consumirlo — parsear configuración en tu app, cargar fixtures de prueba, procesar manifiestos de Kubernetes programáticamente.
  • Usa ruamel.yaml cuando edites YAML que mantienen humanos — actualizar archivos de configuración en su lugar, herramientas que modifican configs de CI, cualquier caso donde perder comentarios sería un problema.
  • ruamel.yaml también es compatible con YAML 1.2 por defecto, lo que significa que el Problema Norway (NOfalse) no te afecta. PyYAML usa YAML 1.1 por defecto.

Manejo de errores

Los errores de parsing YAML lanzan yaml.YAMLError, que es la clase base de todas las excepciones de PyYAML. Captúrala siempre al cargar YAML de fuentes no confiables o proporcionadas por el usuario:

python
import yaml
from pathlib import Path

def load_user_config(path: str) -> dict:
    try:
        with open(path, 'r', encoding='utf-8') as f:
            data = yaml.safe_load(f)
    except FileNotFoundError:
        raise FileNotFoundError(f"Config file not found: {path}")
    except yaml.scanner.ScannerError as e:
        # Includes line/column info in the error message
        raise ValueError(f"YAML syntax error in {path}:\n{e}")
    except yaml.YAMLError as e:
        raise ValueError(f"Invalid YAML in {path}: {e}")

    if data is None:
        return {}
    if not isinstance(data, dict):
        raise TypeError(f"Expected a YAML mapping at top level, got {type(data).__name__}")

    return data
Valida la estructura después de cargar. yaml.safe_load() solo garantiza sintaxis YAML válida — no valida la forma de los datos. Un archivo de configuración con database: al nivel superior está bien; un archivo de configuración con una lista al nivel superior también es YAML válido. Añade comprobaciones de tipo y estructura después de cargar, o usa una biblioteca de validación de esquemas como Pydantic para parsear el dict cargado en un modelo tipado.

Conclusión

PyYAML cubre la gran mayoría del trabajo YAML en Python: usa siempre yaml.safe_load() (no yaml.load()), usa yaml.safe_load_all() para flujos multi-documento, y usa yaml.dump() con default_flow_style=False para una salida legible. Cuando necesites preservar comentarios u obtener la semántica de YAML 1.2, cambia a ruamel.yaml — es una actualización transparente para la lectura y un pequeño cambio de API para la escritura. Para errores de sintaxis antes de que tu código siquiera se ejecute, el Validador YAML te dirá exactamente en qué línea y columna está el problema.