Python で YAML を扱うなら、ほぼ確実に PyYAML を使っているはずです。これは標準的なライブラリで、 2006年から存在し、yaml.load() という関数が含まれていますが、この関数には深刻なセキュリティの脆弱性があり、 多くのチームが痛い目を見てきました。修正方法は一単語 — safe_load — ですが、 なぜそうなのか、何をトレードオフするのか、そしていつ新しい ruamel.yaml ライブラリの方が適切かを理解する必要があります。

このガイドでは、Python における実践的な YAML パースを解説します:安全なロード、マルチドキュメントストリーム、Python オブジェクトの YAML へのシリアライズ、デフォルト値付き設定ファイルのパターン、エラーハンドリング。すべての例は実際のシナリオを使用しており、プレースホルダーデータは使いません。

インストール

bash
pip install pyyaml

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

yaml.safe_load() — 常に使うべき関数

PyYAML について最も重要なことは、yaml.load() が YAML ファイルに埋め込まれた任意の Python コードを実行できるということです。 これは理論上のリスクではなく、よく知られた攻撃ベクトルです。常に 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"]
セキュリティノート: yaml.safe_load() は標準的な YAML 型のみをサポートします: 文字列、数値、真偽値、null、リスト、辞書。YAML に !!python/object のような Python 固有のタグが含まれている場合は ConstructorError を発生させます。これはまさに望ましい動作です。 yaml.full_load() は旧来の素の yaml.load() より安全ですが、 safe_load() ほど制限的ではありません。safe_load() から始めて、本当に必要な場合のみアップグレードしましょう。

YAML 設定ファイルの読み込み

Web アプリケーション向けの現実的な設定読み込みパターンを紹介します。YAML 設定ファイルを読み込み、 Python の辞書マージを使って未指定の項目にデフォルト値を適用します:

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)

Python オブジェクトを YAML にシリアライズする

yaml.dump() は Python の辞書、リスト、文字列、数値、真偽値、None を YAML にシリアライズします。 デフォルトではフロースタイル(インラインの波括弧)を使用します — 読みやすいブロックスタイルにするには default_flow_style=False を設定してください:

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)

load_all によるマルチドキュメントストリーム

YAML は --- で区切られた複数のドキュメントを1つのファイルに含めることができます。Kubernetes の マニフェストではよく見られるパターンで、1つのファイルに Deployment、Service、ConfigMap が含まれることがあります。 すべてのドキュメントを反復処理するには yaml.safe_load_all() を使います:

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

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 — コメントを保持する必要があるとき

PyYAML には重要な制限があります:読み込み時にコメントが削除されます。YAML ファイルを読み込んで 変更して書き戻すと、コメントはすべて消えてしまいます。人間がメンテナンスする設定ファイルでは、 コメントが失われることは致命的です。

ruamel.yaml はコメント、キーの順序、フォーマットを保持するラウンドトリップパーサーを実装しており、 デフォルトで YAML 1.2 仕様 を対象としています。 人間が後で読む YAML をプログラムで編集する際には、これが正しい選択です:

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
  • PyYAML を使うのは YAML を消費目的で読み込む場合 — アプリに設定をパースする、テストフィクスチャを読み込む、Kubernetes マニフェストをプログラムで処理するなど。
  • ruamel.yaml を使うのは人間がメンテナンスする YAML を編集する場合 — 設定ファイルをその場で更新する、CI 設定を変更するツール、コメントが失われると困るあらゆるケース。
  • ruamel.yaml はデフォルトで YAML 1.2 準拠なので、Norway 問題(NOfalse)の影響を受けません。PyYAML はデフォルトで YAML 1.1 を使用します。

エラーハンドリング

YAML のパースエラーは yaml.YAMLError を発生させます。これはすべての PyYAML 例外の基底クラスです。 信頼できないソースやユーザー提供のソースから YAML を読み込む際は、必ずキャッチしてください:

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
読み込み後に構造を検証しましょう。 yaml.safe_load() が保証するのは 有効な YAML 構文のみです — データの形は検証しません。トップレベルに database: がある設定ファイルは正しいですが、 トップレベルにリストがある設定ファイルも有効な YAML です。読み込み後に型と構造のチェックを追加するか、 Pydantic のようなスキーマ検証ライブラリを使って 読み込んだ辞書を型付きモデルにパースしましょう。

まとめ

PyYAML は Python における YAML 作業の大部分をカバーします:常に yaml.safe_load() を使い(yaml.load() ではなく)、マルチドキュメントストリームには yaml.safe_load_all() を使い、 読みやすい出力には yaml.dump()default_flow_style=False を指定して使います。 コメントを保持したり YAML 1.2 のセマンティクスが必要なら、ruamel.yaml に切り替えましょう — 読み込みはドロップイン置き換えで、書き込みは少し API が変わるだけです。コードが実行される前の構文エラーには、 YAML バリデーターが正確な行と列を教えてくれます。