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
pip install pyyaml
# For ruamel.yaml (covered later)
pip install ruamel.yamlyaml.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():
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"]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:
# config.yaml
database:
host: postgres.internal
port: 5432
name: myapp_prod
pool_size: 10
redis:
host: redis.internal
port: 6379
logging:
level: INFO
format: jsonimport 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:
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:
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-configTambién puedes escribir múltiples documentos en un flujo con yaml.dump_all():
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: 80ruamel.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:
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 (
NO→false) 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:
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 datayaml.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.