Add the oneyaml crate, read YAML into Rust types with serde, write it back, edit a file in place without losing its comments, and apply JSON Patch and merge patches.
oneyaml is serde and lossless, comment-preserving editing for YAML 1.2, in one crate. Both run on one parser, so a typed read and an edit never disagree about what a file means. Every sample on this page was compiled and run against oneyaml 1.2.0 from crates.io; the output under each one is what it printed.
Install
oneyaml needs Rust 1.85 or newer (edition 2024).
cargo new yaml-start cd yaml-start cargo add [email protected] serde -F serde/derive
The only runtime dependency by default is serde. The crate has no unsafe code (#![forbid(unsafe_code)]), and every fallible call returns a Result or an Option instead of panicking. The license is MIT or Apache-2.0, at your choice.
The samples are complete main.rs files. A few read a file from the current directory; the YAML they read is shown with them.
Read YAML into a struct
Derive Deserialize and call oneyaml::from_str. Fields bind by name, the way they do with serde_json.
use serde::Deserialize; #[derive(Debug, Deserialize)] struct Config { name: String, replicas: u32, ports: Vec<u16>, debug: bool, } fn main() -> Result<(), oneyaml::Error> { let src = "\ name: api replicas: 2 ports: [80, 443] debug: false "; let cfg: Config = oneyaml::from_str(src)?; println!( "{} runs {} replicas on {:?} (debug: {})", cfg.name, cfg.replicas, cfg.ports, cfg.debug ); Ok(()) }
api runs 2 replicas on [80, 443] (debug: false)YAML 1.2 is the default: yes and no are strings, not booleans, and 0755 is the decimal 755. A field of the wrong type is an error with a code, a line and a column, and the path to the value (see errors with line and column).
Write YAML
oneyaml::to_string writes any Serialize value as block-style YAML.
use serde::Serialize; use std::collections::BTreeMap; #[derive(Serialize)] struct Config { name: String, replicas: u32, ports: Vec<u16>, labels: BTreeMap<String, String>, } fn main() -> Result<(), oneyaml::Error> { let cfg = Config { name: "api".into(), replicas: 3, ports: vec![80, 443], labels: BTreeMap::from([("tier".into(), "web".into())]), }; print!("{}", oneyaml::to_string(&cfg)?); Ok(()) }
name: api replicas: 3 ports: - 80 - 443 labels: tier: web
to_writer writes to any io::Write, and to_string_with takes Settings for the indent and other output choices.
Edit a file and keep its comments
To change a file that people also edit by hand, parse it into a Document instead. An untouched document writes back byte for byte; an edit changes only what it touches, and every comment stays with the entry it belongs to.
use oneyaml::Document; fn main() -> Result<(), Box<dyn std::error::Error>> { let src = "\ # Deploy settings name: api replicas: 2 # raised by the release job ports: [80] "; let mut doc = Document::parse(src)?; // Read a value. let replicas = doc.get_path("replicas").and_then(|n| n.as_u64()); println!("replicas = {replicas:?}"); // Change a value, append to a list, add a key. doc.set_path("replicas", 3)?; doc.get_mut("ports")?.push(443)?; doc.root_mut()?.insert("region", "us-east-1")?; print!("{doc}"); Ok(()) }
replicas = Some(2) # Deploy settings name: api replicas: 3 # raised by the release job ports: [80, 443] region: us-east-1
Paths are strings such as "server.port" or "spec.containers[0].image". set_path keeps the old value’s style: a quoted string stays quoted, a block scalar stays a block scalar. New content is laid out like its neighbors, so the flow list [80] stays a flow list. The rules for which entry a comment belongs to are in comment attachment.
Apply a JSON Patch or a merge patch
A Document applies JSON Patch (RFC 6902) and merge patches (RFC 7396). Both are all or nothing: if any operation fails, the document is exactly as it was. Like the setters, a patch changes only the text of the values it names.
use oneyaml::{Document, MergePatch, Patch}; fn main() -> Result<(), Box<dyn std::error::Error>> { let src = "\ # Web service image: web:1.0 # pinned env: DEBUG: \"1\" LOG: info ports: [80] "; // JSON Patch (RFC 6902): all or nothing. let mut doc = Document::parse(src)?; let patch = Patch::from_json( r#"[{"op": "replace", "path": "/image", "value": "web:1.1"}, {"op": "add", "path": "/ports/-", "value": 443}]"#, )?; doc.apply_patch(&patch)?; print!("{doc}"); println!("---"); // Merge patch (RFC 7396): null removes a key. let mut doc = Document::parse(src)?; let merge = MergePatch::from_json(r#"{"env": {"DEBUG": null, "LOG": "warn"}}"#)?; doc.apply_merge_patch(&merge)?; print!("{doc}"); Ok(()) }
# Web service image: web:1.1 # pinned env: DEBUG: "1" LOG: info ports: [80, 443] --- # Web service image: web:1.0 # pinned env: LOG: warn ports: [80]
Patches work on the YAML data model: test compares YAML values, so 1 is not 1.0, and writes below an alias are refused. A move carries the value without its comments; to move an entry with them, use NodeMut::move_entry. Every difference from JSON is listed in patches and pointers.
Where next
Recipes, each a complete program:
- Parse YAML into Rust structs and read YAML without a schema
- Edit YAML and keep its comments and merge or patch YAML
- Convert YAML to JSON and back and read Kubernetes manifests and multi-document files
- Report errors with line and column, anchors, aliases and merge keys
- Read untrusted YAML safely and read YAML 1.1 files (
yes,no,0755)
The docs:
- The crate guide: diagnostics, patches and pointers, untrusted input, merge keys, feature flags. The API reference with an example for every task is on docs.rs.
- Moving from
serde_yaml,serde_yaml_ngorserde_norway: the migration guide. - Choosing a crate: how oneyaml compares, performance and yaml-test-suite conformance.
- What changed: the changelog.
Choosing a Rust YAML crate after serde_yaml: Rust YAML parser.