Skip to content

Features

Features

pyrs-yaml is designed to be a drop-in replacement for PyYAML while adding powerful features that PyYAML lacks.

YAML 1.2 Compliance

Powered by granit-parser, pyrs-yaml achieves 99.75% pass rate (405/406) on the YAML Test Suite.

Perfect Round-Trip

Unlike PyYAML, pyrs-yaml preserves all formatting and metadata:

  • Comments — standalone and inline
  • Anchors (&name) and aliases (*name)
  • Tags (!!str, !!int, etc.)
  • Chomping indicators (\|-, \|+, >-, >+)
  • Scalar styles (plain, single-quoted, double-quoted, literal, folded)
  • Flow/block formatting[]/{} vs block style preserved

Performance

Benchmark environment

All benchmarks are measured via CodSpeed CI (pytest-codspeed, WallTime mode). Relative speedups (×N) are consistent across hardware but absolute times may vary.

Rust backend delivers 21–43× faster parsing and 55–177× faster serialization over PyYAML:

Operation pyrs-yaml PyYAML
Parse (large) 1.5 ms 57.7 ms
Serialize (large) 0.17 ms 30.2 ms
Round-trip 1.6 ms 87.9 ms

Custom AST

The CustomNode AST gives you full control over YAML structure:

  • Inspect and modify nodes programmatically
  • Add custom metadata (comments, anchors, tags)
  • Build YAML from scratch with full formatting control
  • Advanced use cases: template engines, config generators, code formatters

PyYAML Compatibility

Drop-in replacement with familiar API:

PyYAML-compatible API
import pyrs_yaml as yaml  # Use as 'yaml' for easy migration

yaml.safe_load(yaml_text)
yaml.safe_dump(data)
yaml.safe_loads(yaml_text)
yaml.safe_dumps(data)

Async I/O

Non-blocking serialization and parsing via asyncio:

Async dump and load
import asyncio
import pyrs_yaml


async def main():
    yaml = await pyrs_yaml.safe_dump_async({"a": 1})
    data = await pyrs_yaml.safe_loads_async(yaml)
    print(data)  # {'a': 1}


asyncio.run(main())

Available functions: safe_dump_async, safe_load_async, safe_loads_async.

JSON Schema Validation

Validate parsed YAML documents against JSON Schema:

JSON Schema validation
doc = pyrs_yaml.parse("name: Alice\nage: 30")
doc.validate({"type": "object", "properties": {"name": {"type": "string"}}})

# Schema as JSON string
doc.validate('{"type": "object", "required": ["name"]}')

Raises YamlValidateError on validation failure.

YAML Schema Language

Define custom schemas that control how plain scalars resolve to Python types:

Register a custom schema
import pyrs_yaml

# Register a custom schema from a YAML string
pyrs_yaml.register_schema(
    "hex",
    """
name: hex
extends: core
rules:
  - pattern: ^0x[0-9a-fA-F]+$
    type: int
""",
)

# Use with YAML instance or module-level functions
y = pyrs_yaml.YAML(schema="hex")
doc = y.parse("addr: 0xFF")
assert doc.get("addr") == 255

d = pyrs_yaml.safe_load("addr: 0x1F", schema="hex")
assert d["addr"] == 31

Or pass a dict inline instead of registering:

Inline schema dict
d = pyrs_yaml.safe_load(
    "addr: 0xFF",
    schema={
        "extends": "core",
        "rules": [{"pattern": "^0x[0-9a-fA-F]+$", "type": "int"}],
    },
)
  • extends — optional base schema (core, json, failsafe, yaml1.1)
  • rules — ordered list of {pattern, type}; first match wins
  • validate — optional structural validation rules: path-qualified types ($.port: int), container checks (sequence_of, mapping_of), and required presence; use validate_against_schema(data, schema_yaml) to check documents
  • Supported types: null, bool, int, float, str
  • Built-in Core schema still uses zero-cost match dispatch (unaffected)
  • File I/Oload_schema(name, path) loads a schema from a YAML file; list_schemas() returns all registered schemas

Duplicate Keys

By default, duplicate mapping keys raise YamlDuplicateKeyError:

Duplicate key error
pyrs_yaml.parse("key: first\nkey: second")
# pyrs_yaml.YamlDuplicateKeyError: duplicate key: key

Pass allow_duplicate_keys=True to keep the last value:

Allow duplicate keys
doc = pyrs_yaml.parse("key: first\nkey: second", allow_duplicate_keys=True)
doc.get("key")  # "second"

The switch applies to parse, safe_load, safe_loads, parse_file, parse_all_docs, and YAML(allow_duplicate_keys=True). In round-trip mode, documents with duplicate keys allowed serialize with the last key-value pair emitted.

Serialization Options

to_yaml_with_options() controls indentation and line wrapping:

Serialization options
yaml_str = doc.to_yaml_with_options(
    indent_size=2,  # base indent (used when per-type options omitted)
    width=80,  # line wrap width; 0 disables wrapping
    indent_mapping=4,  # block mapping indent per level
    indent_sequence=2,  # block sequence indent per level
    indent_offset=0,  # base offset for the entire document
)

When indent_mapping / indent_sequence / indent_offset are omitted, they default to indent_size / indent_size / 0 respectively, so indent_size=4 still indents all levels by 4.

Custom Tag Handlers

Register handlers for custom YAML tags that transform scalar values:

Import
import pyrs_yaml
@pyrs_yaml.register_tag("!custom")
def custom_handler(node):
    return f"custom:{node}"
pyrs_yaml.register_tag("!custom", lambda node: node.upper())
Use a custom tag
doc = pyrs_yaml.parse("name: !custom value")
doc.get("name")  # "custom:value"
  • Multiple handlers for the same tag execute in ascending priority order; raising YamlTagSkip delegates to the next handler.
  • Handlers must return a string, otherwise YamlTagError is raised.
  • remove_tag("!custom") and clear_tag_handlers() unregister handlers.

Community Plugins

Define custom YAML node types that integrate with serialization and deserialization:

CustomType plugin
import pyrs_yaml
from datetime import datetime


class TimestampType(pyrs_yaml.CustomType):
    python_type = datetime

    def from_yaml(self, value: str):
        return datetime.fromisoformat(value)

    def to_yaml(self, obj) -> str:
        return obj.isoformat()


# Register imperative or decorator
pyrs_yaml.register_type("!timestamp", TimestampType())

# Load: tagged scalar → Python object
doc = pyrs_yaml.parse("when: !timestamp 2026-08-11T10:30:00")
assert isinstance(doc.get("when"), datetime)

# Dump: Python object → tagged scalar
data = {"ts": datetime(2026, 8, 11, 10, 30)}
out = pyrs_yaml.safe_dump(data)
# out contains: ts: !timestamp 2026-08-11T10:30:00

Built-in plugins (registered at import time): !timestampdatetime, !datedatetime.date, !timedatetime.time, !uuiduuid.UUID, !decimaldecimal.Decimal, !binarybytes, !regexre.Pattern, !setstr

Optional third-party plugins (auto-registered when the library is installed): !durationpendulum.Duration, !arrowarrow.Arrow, !ulidulid.ULID

Method Description
can_parse(node) Whether this type handles a given AST node
from_yaml(value) Convert YAML string → Python object
to_yaml(obj) Convert Python object → YAML string
validate(obj) Validate a Python object (returns bool)

Pydantic Integration

Parse YAML directly into Pydantic models, or serialize models to YAML:

Pydantic integration
from pydantic import BaseModel
import pyrs_yaml


class User(BaseModel):
    name: str
    age: int


# Parse YAML into a Pydantic model
user = pyrs_yaml.parse_as(User, "name: Alice\nage: 30")
print(user.name)  # Alice

# Serialize a model to YAML string
yaml_str = pyrs_yaml.dump_pydantic(user)
print(yaml_str)

pydantic-settings

PyrsYamlConfigSettingsSource is a drop-in replacement for pydantic_settings.YamlConfigSettingsSource: it feeds the same BaseSettings and SettingsConfigDict(yaml_file=...) workflow but parses with pyrs-yaml instead of PyYAML — so values follow the YAML 1.2 core schema (e.g. on stays a string), and pyrs-yaml's performance carries into your settings loading.

pydantic-settings source
from pydantic_settings import BaseSettings, SettingsConfigDict
import pyrs_yaml


class Settings(BaseSettings):
    app_name: str

    model_config = SettingsConfigDict(yaml_file="config.yaml")

    @classmethod
    def settings_customise_sources(
        cls, settings_cls, init_settings, env_settings, dotenv_settings, file_secret_settings
    ):
        return (
            init_settings,
            env_settings,
            dotenv_settings,
            file_secret_settings,
            pyrs_yaml.PyrsYamlConfigSettingsSource(settings_cls),
        )


settings = Settings()  # loaded from config.yaml via pyrs-yaml

Install with pip install "pyrs-yaml[settings]" (requires Python 3.10+).

Incremental Re-parse

Re-parse stored source text in place with different options:

Incremental re-parse
doc = pyrs_yaml.parse("x: on")
print(doc.get("x"))  # "on" (string, core schema)

doc.reparse(schema="yaml1.1")
print(doc.get("x"))  # True (bool, yaml1.1 schema)

In-Place Editing

Edit a parsed document without losing any formatting metadata — comments, anchors, tags, scalar styles, and flow/block style all survive:

In-place editing
doc = pyrs_yaml.parse("""
server:
  host: localhost  # bind address
  ports:
    - 8080
""")

doc.set("$.server.host", "0.0.0.0")  # (1)!
doc.insert("$.server.ports", 0, 80)  # (2)!
doc.append("$.server.ports", 443)  # (3)!
doc.rename("$.server", "srv")  # (4)!
del doc["server"]  # or: doc.delete("$.server")
  1. set replaces the value at a path, preserving the inline comment.
  2. insert adds an element at a sequence index.
  3. append adds to the end of a sequence.
  4. rename changes a mapping key in place, keeping position and comments.
  • Path API — JSONPath-style paths ($.a.b[0]) with root sugar (doc["k"] = v, del doc["k"])
  • Node APIdoc.node().find(path) returns Node objects with set_value / insert / append / delete / rename, plus tree traversal (parent, children, walk, filter)
  • Atomicity — failed edits leave the document (and its revision) untouched
  • Metadata preservation — replaced scalars keep their comment/anchor/tag/quoting; renamed keys keep position and comments
  • Metadata manipulationNode.set_comment() / set_anchor() / set_tag() / remove_* let you read and write comments, anchors, and tags through the Node API
  • Style/format controlNode.set_scalar_style() / set_flow_style() / set_chomping() switch scalar quoting, block/flow layout, and chomping indicators
  • Deep editingdoc.set_many({path: value}) applies wildcard paths ($.items[*].active) in one splice burst; doc.sort_keys() orders mapping keys; Node.move(new_path) relocates subtrees; Node.copy() deep-copies subtrees
  • Alias-aware — setting an alias's own path replaces it in place; editing through an alias raises YamlEditError

See the In-Place Editing guide for details.

NumPy ndarray Support

pyrs-yaml can serialize numpy.ndarray objects of any dimension directly to YAML:

NumPy ndarray serialization
import numpy as np
import pyrs_yaml

# 1-D array
arr = np.array([1, 2, 3], dtype="int32")
yaml_str = pyrs_yaml.safe_dump(arr)
# - 1
# - 2
# - 3

# 2-D matrix
matrix = np.array([[1, 2], [3, 4]], dtype="float64")
yaml_str = pyrs_yaml.safe_dump(matrix)
# -
#   - 1.0
#   - 2.0
# -
#   - 3.0
#   - 4.0

# Round-trip
loaded = pyrs_yaml.safe_load(yaml_str)
assert loaded == [[1.0, 2.0], [3.0, 4.0]]

Supported dtypes

Type Rust Backend YAML Output
int8/16/32/64 PyUntypedArrayPyArrayDyn<i8/i16/i32/i64> Plain integer (quoted if negative)
uint8/16/32/64 PyUntypedArrayPyArrayDyn<u8/u16/u32/u64> Plain integer
float32/64 PyUntypedArrayPyArrayDyn<f32/f64> Plain float (quoted if negative)
complex64/128 PyUntypedArrayPyArrayDyn<Complex64/Complex32> (re+imj) string
bool PyUntypedArrayPyArrayDyn<bool> true / false
nan / inf NaN / .inf / -.inf

Notes

  • Zero-copy: Uses the numpy Rust crate's PyUntypedArray for type-erased array access, then dispatches to the correct typed PyArrayDyn<T> for zero-copy slice iteration
  • GIL released: Slice iteration runs outside the GIL for maximum performance on large arrays

Negative scalars

YAML 1.2 block sequences cannot contain a plain scalar starting with - followed by whitespace. Plain negative numbers (-1, -3.14) are emitted unquoted and round-trip by value; quoted numeric-looking strings (e.g. "-1") stay strings.

  • Negative numbers: plain negative values are emitted unquoted and round-trip by value; only plain scalars are type-resolved (YAML 1.2), so quoted numeric-looking strings stay strings
  • 0-D arrays: Reshaped to 1-D and serialized as a single-item list
  • Complex numbers: YAML has no native complex type; serialized as (re+imj) strings. safe_load returns them as strings, not Python complex
  • Markdown frontmatter extractionread_markdown() for blog/content tools
  • JSON ↔ YAML conversionfrom_json() / from_dict()
  • Pydantic integrationparse_as() / dump_pydantic()
  • Multi-document parsingparse_all_docs()
  • i18n error messagesset_language("zh-CN") for bilingual errors
  • Type hints — PEP 561 typed package marker (py.typed) for mypy support

Supported YAML Constructs

Feature Support
YAML 1.2 spec Full
Comments (standalone) Preserved
Comments (inline) Preserved
Anchors & aliases Preserved
Tags (explicit) Preserved
Block scalars (\|, >) Preserved
Chomping indicators Preserved
Flow collections ({}, []) Preserved
Merge keys (<<) Resolved
Complex keys Supported
Escape sequences Supported
Multi-document Supported
Async I/O safe_*_async
JSON Schema validation doc.validate()
Incremental re-parse doc.reparse()
In-place editing doc.set() / insert() / append() / delete() / rename()
Metadata editing Node.set_comment() / set_anchor() / set_tag()
Style/format control Node.set_scalar_style() / set_flow_style() / set_chomping()
Deep editing doc.set_many() / sort_keys() / Node.move() / copy()
Schema validation validate_against_schema()
Schema file IO load_schema() / list_schemas()
JSON export doc.to_json()