---
title: "Edit YAML without losing comments"
url: https://recursiveai.org/oneyaml/articles/edit-yaml-keep-comments/
description: "Edit a YAML file from code and keep its comments, blank lines and quotes. Why most YAML libraries drop them, and the same edit in Go, Rust and C# that doesn’t."
date: 2026-10-26
author: oneyaml team
---

# Edit YAML without losing comments

Edit a YAML file from code and keep its comments, blank lines and quotes. Why most YAML libraries drop them, and the same edit in Go, Rust and C# that doesn’t.

October 26, 2026 · oneyaml team

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*

```
# 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:

*main.go · go.yaml.in/yaml/v3 v3.0.5*

```
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))
}
```

*Output*

```
# 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](https://pkg.go.dev/go.yaml.in/yaml/v3#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](https://recursiveai.org/oneyaml/articles/yamldotnet-alternative/)). 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

*main.go · oneyaml.dev/go v1.0.0*

```
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

*main.rs · oneyaml 1.2.0*

```
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#

*Program.cs · OneYaml 1.2.1 · .NET 10*

```
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

*Output · Go, Rust and C#*

```
# 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:

*Diff*

```
--- 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/go@v1.0.0` (Go 1.24+), `cargo add oneyaml@1.2.0` (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](https://recursiveai.org/oneyaml/rust/docs/comment-attachment/), 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_path` through 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 `yes` is a string in YAML 1.2 and a boolean to YAML 1.1 programs. If such programs read the file too, Go’s `YAML11Strings()` and C#’s `QuoteYaml11Lookalikes` quote those strings.

## Writing comments, not just keeping them

The three libraries differ here:

- **Go:** `SetHeadComment` writes the comment above an entry and `SetLineComment` the one after its value; `Rename` renames a key in place ([editing](https://recursiveai.org/oneyaml/go/docs/editing/), [recipe](https://recursiveai.org/oneyaml/go/cookbook/edit-yaml-keep-comments/)).
- **Rust:** `set_trailing`, `set_leading` and `Document::set_header` write comments, and `explain_comment` says which rule attached a comment to its entry ([recipe](https://recursiveai.org/oneyaml/rust/cookbook/edit-yaml-keep-comments/)).
- **C#:** `YamlDocument` keeps comments through edits but can’t read, add or move them ([editing](https://recursiveai.org/oneyaml/csharp/docs/editing/), [recipe](https://recursiveai.org/oneyaml/csharp/cookbook/edit-yaml-keep-comments/)).

## 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](https://github.com/goccy/go-yaml)).
- **yaml-edit** (Rust) is a lossless editor that “preserves all whitespace, comments, and original formatting” ([README](https://github.com/jelmer/yaml-edit)). 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 `Node` but 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](https://recursiveai.org/oneyaml/articles/json-patch-yaml/) does it to the same file, with the same result. To pick a library per language, see the guides for [Go](https://recursiveai.org/oneyaml/articles/golang-yaml/), [Rust](https://recursiveai.org/oneyaml/articles/rust-yaml-parser/) and [C#](https://recursiveai.org/oneyaml/articles/csharp-yaml-parser/), or the [oneyaml home page](https://recursiveai.org/oneyaml/).
