To edit YAML and keep its comments, change the file through a lossless document model rather than loading it into data and writing it back out. Maps, structs and classes have nowhere to keep a comment, so a load-and-save round trip drops comments, and even node trees that do keep comments write the file out again in their own layout. A lossless document keeps the original text and rewrites only the values you change. This page shows the difference on one file, then the same edit in Go, Rust and C# with oneyaml, which gives the same bytes in all three.
The file, and what a round trip does to it
A values file of the kind people maintain by hand, with comments, blank lines and a quoted version string:
# values.yaml for the web service image: repository: registry.example.com/web tag: "1.4.2" # set by CI replicas: 2 # scaled by the release job # Hosts served by the ingress hosts: - example.com - www.example.com
In Go, the usual answer to “how do I keep comments” is yaml.v3’s yaml.Node, which records comments on each node. Load the file into a Node, change replicas, and marshal it back:
package main import ( "fmt" "log" "os" "go.yaml.in/yaml/v3" ) func main() { src, err := os.ReadFile("values.yaml") if err != nil { log.Fatal(err) } var doc yaml.Node if err := yaml.Unmarshal(src, &doc); err != nil { log.Fatal(err) } // The document node holds one mapping; its Content alternates keys and values. root := doc.Content[0] for i := 0; i < len(root.Content); i += 2 { if root.Content[i].Value == "replicas" { root.Content[i+1].Value = "3" } } out, err := yaml.Marshal(&doc) if err != nil { log.Fatal(err) } fmt.Print(string(out)) }
# values.yaml for the web service image: repository: registry.example.com/web tag: "1.4.2" # set by CI replicas: 3 # scaled by the release job # Hosts served by the ingress hosts: - example.com - www.example.com
The comments survive, but the file doesn’t: both blank lines are gone, every indent is now four spaces, and the gaps before the trailing comments have collapsed. yaml.v3 documents this: “the content when re-encoded will not have its original textual representation preserved” (Node). Other libraries do less. Run through the same change, YamlDotNet 18.1.0’s YamlStream dropped all four comments and both blank lines (the program and its output). Rust’s serde_yaml_ng 0.10.0, through its Value, dropped the comments and blank lines and wrote the tag without its quotes.
The same edit in Go, Rust and C#
Each program below reads values.yaml, sets the image tag, sets replicas, appends a host and writes the file back. All three print the same bytes.
Go
package main import ( "fmt" "log" "os" oneyaml "oneyaml.dev/go" ) func main() { src, err := os.ReadFile("values.yaml") if err != nil { log.Fatal(err) } doc, err := oneyaml.Parse(src) if err != nil { log.Fatal(err) } for _, e := range []struct { ptr string v any }{ {"/image/tag", "1.5.0"}, // stays double-quoted {"/replicas", 3}, // its comment stays {"/hosts/-", "api.example.com"}, // block style, like its siblings } { if err := doc.Set(e.ptr, e.v); err != nil { log.Fatal(err) } } if err := os.WriteFile("values.yaml", []byte(doc.String()), 0o644); err != nil { log.Fatal(err) } fmt.Print(doc) }
Rust
use oneyaml::Document; fn main() -> Result<(), Box<dyn std::error::Error>> { let src = std::fs::read_to_string("values.yaml")?; let mut doc = Document::parse(&src)?; doc.set_path("image.tag", "1.5.0")?; // stays double-quoted doc.set_path("replicas", 3)?; // its comment stays doc.get_mut("hosts")?.push("api.example.com")?; // block style, like its siblings std::fs::write("values.yaml", doc.to_string())?; print!("{doc}"); Ok(()) }
C#
using OneYaml; using var doc = YamlDocument.Parse(File.ReadAllText("values.yaml")); doc.Set("/image/tag", "1.5.0"); // stays double-quoted doc.Set("/replicas", 3); // its comment stays doc.Add("/hosts/-", "api.example.com"); // block style, like its siblings File.WriteAllText("values.yaml", doc.ToString()); Console.Write(doc);
The output of all three
# values.yaml for the web service image: repository: registry.example.com/web tag: "1.5.0" # set by CI replicas: 3 # scaled by the release job # Hosts served by the ingress hosts: - example.com - www.example.com - api.example.com
The diff against the original is three lines, the three values the program set:
--- values.yaml +++ values.yaml (edited) @@ -1,11 +1,12 @@ # values.yaml for the web service image: repository: registry.example.com/web - tag: "1.4.2" # set by CI + tag: "1.5.0" # set by CI -replicas: 2 # scaled by the release job +replicas: 3 # scaled by the release job # Hosts served by the ingress hosts: - example.com - www.example.com + - api.example.com
The quotes around "1.5.0" stay because a value is rewritten in the style it had. The comments after tag and replicas keep their column, and the new host is a block item indented like its siblings. Paths are JSON Pointers in Go and C# (/image/tag, /hosts/- for the end of a list) and dotted paths in Rust (image.tag).
To install: go get oneyaml.dev/[email protected] (Go 1.24+), cargo add [email protected] (Rust 1.85+), or dotnet add package OneYaml --version 1.2.1 (.NET 8 or 10).
What an edit keeps, and what it doesn’t
- Untouched text stays byte for byte. Parse a document and write it back without changes, and you get the input.
- A changed value keeps its style when that style can hold the new value: quoted stays quoted, a block literal stays a block literal. Otherwise only that value falls back to a style that can.
- Comments go with their entry. Remove a list item and its trailing comment goes with it. In Rust, which entry owns each comment is set by numbered rules, and moving an entry moves its comments.
- New keys and items copy their neighbors: a new item in a flow list like
[80]stays in the flow list. - Aliases: in Go and C#, an edit through an alias is refused, because it would change every place that shares the anchored node. In Rust,
set_paththrough an alias changes the anchored node itself, so every alias of it sees the new value. A JSON Patch refuses writes below an alias in all three. - A YAML 1.1 reader may see a new value differently. A new plain
yesis a string in YAML 1.2 and a boolean to YAML 1.1 programs. If such programs read the file too, Go’sYAML11Strings()and C#’sQuoteYaml11Lookalikesquote those strings.
Writing comments, not just keeping them
The three libraries differ here:
- Go:
SetHeadCommentwrites the comment above an entry andSetLineCommentthe one after its value;Renamerenames a key in place (editing, recipe). - Rust:
set_trailing,set_leadingandDocument::set_headerwrite comments, andexplain_commentsays which rule attached a comment to its entry (recipe). - C#:
YamlDocumentkeeps comments through edits but can’t read, add or move them (editing, recipe).
Other libraries that keep comments
- goccy/go-yaml (Go) exposes an AST and lists “reversible transformation” of YAML with comments, anchors and aliases among its features (README).
- yaml-edit (Rust) is a lossless editor that “preserves all whitespace, comments, and original formatting” (README). Its README covers parsing and editing, not serde; oneyaml has both on one parser.
- yaml.v3 and go.yaml.in/yaml/v4 (Go) keep comments on
Nodebut re-encode the layout, as shown above.
To change many values at once from a JSON document, apply a patch instead: JSON Patch for YAML does it to the same file, with the same result. To pick a library per language, see the guides for Go, Rust and C#, or the oneyaml home page.