Recursive AI

Apply a JSON Patch to a YAML file

Apply a JSON Patch (RFC 6902) to a YAML file without converting it to JSON, so comments, quotes and key order stay. Code in Go, Rust and C#, plus merge patches.

A JSON Patch (RFC 6902) describes changes to a document’s data, not to its JSON text, so it applies to YAML as well. The common way to do it converts the YAML to JSON, patches the JSON, and converts back, which loses every comment and reorders keys. Applying the patch to the YAML document itself changes only the values the patch names. oneyaml does that in Go, Rust and C#, atomically: if any operation fails, the file is left exactly as it was.

The file and the patch

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

The patch checks the current tag with test, then bumps it, scales replicas and appends a host. /hosts/- is RFC 6901 for the end of the list:

patch.json
[
  { "op": "test", "path": "/image/tag", "value": "1.4.2" },
  { "op": "replace", "path": "/image/tag", "value": "1.5.0" },
  { "op": "replace", "path": "/replicas", "value": 3 },
  { "op": "add", "path": "/hosts/-", "value": "api.example.com" }
]

The usual route: through JSON

In Go this is often done with sigs.k8s.io/yaml, which converts between YAML and JSON, and evanphx/json-patch, which applies the patch, following each project’s README:

main.gosigs.k8s.io/yaml v1.6.0 · json-patch v5.9.11
package main
 
import (
    "fmt"
    "log"
    "os"
 
    jsonpatch "github.com/evanphx/json-patch/v5"
    "sigs.k8s.io/yaml"
)
 
func main() {
    src, err := os.ReadFile("values.yaml")
    if err != nil {
        log.Fatal(err)
    }
    patchJSON, err := os.ReadFile("patch.json")
    if err != nil {
        log.Fatal(err)
    }
 
    // YAML to JSON, patch the JSON, JSON back to YAML.
    doc, err := yaml.YAMLToJSON(src)
    if err != nil {
        log.Fatal(err)
    }
    patch, err := jsonpatch.DecodePatch(patchJSON)
    if err != nil {
        log.Fatal(err)
    }
    modified, err := patch.Apply(doc)
    if err != nil {
        log.Fatal(err)
    }
    out, err := yaml.JSONToYAML(modified)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Print(string(out))
}
Output
hosts:
- example.com
- www.example.com
- api.example.com
image:
  repository: registry.example.com/web
  tag: 1.5.0
replicas: 3

The data is right, but the file is not the one you started with: the four comments and both blank lines are gone, the keys are in alphabetical order, the quotes around the tag are dropped, and the list is no longer indented under hosts. Commit that, and the diff covers the whole file. This is the trip any JSON-based route takes, because JSON has no comments.

Apply the patch to the YAML itself

Each program reads the same two files, applies the patch, prints the result, and then applies the patch a second time to show the test guard at work.

Go

main.gooneyaml.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)
    }
    patch, err := os.ReadFile("patch.json")
    if err != nil {
        log.Fatal(err)
    }
    doc, err := oneyaml.Parse(src)
    if err != nil {
        log.Fatal(err)
    }
 
    if err := doc.ApplyPatch(patch); err != nil {
        log.Fatal(err)
    }
    fmt.Print(doc)
 
    // Run it again: the test fails, and nothing changes.
    before := doc.String()
    err = doc.ApplyPatch(patch)
    fmt.Printf("second run: %v; unchanged: %v\n", err, doc.String() == before)
}
Output
# 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
second run: oneyaml: patch operation 0 (test "/image/tag"): patch test failed; unchanged: true

Rust

main.rsoneyaml 1.2.0
use oneyaml::{Document, Patch};
 
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut doc = Document::parse(&std::fs::read_to_string("values.yaml")?)?;
    let patch = Patch::from_json(&std::fs::read_to_string("patch.json")?)?;
 
    doc.apply_patch(&patch)?;
    print!("{doc}");
 
    // Run it again: the test fails, and nothing changes.
    let before = doc.to_string();
    let err = doc.apply_patch(&patch).err().ok_or("expected the test to fail")?;
    println!("second run refused at op {:?}; unchanged: {}", err.op_index(), doc.to_string() == before);
    Ok(())
}
Output
# 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
second run refused at op Some(0); unchanged: true

C#

Program.csOneYaml 1.2.1 · .NET 10
using OneYaml;
 
using var doc = YamlDocument.Parse(File.ReadAllText("values.yaml"));
string patch = File.ReadAllText("patch.json");
 
doc.ApplyPatch(patch);
Console.Write(doc);
 
// Run it again: the test fails, and nothing changes.
string before = doc.ToString();
try
{
    doc.ApplyPatch(patch);
}
catch (YamlException e)
{
    Console.WriteLine($"second run refused at op {e.OperationIndex}; unchanged: {doc.ToString() == before}");
}
Output
# 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
second run refused at op 0; unchanged: True

All three print the same YAML: three values changed, and every comment, blank line, quote and key position kept. The second run fails at operation 0 in all three, because the tag is no longer 1.4.2, and the document is unchanged. That makes a patch with a test safe to run twice, for example from a CI job that may retry.

To install: go get oneyaml.dev/[email protected], cargo add [email protected], or dotnet add package OneYaml --version 1.2.1.

Where YAML rules differ from JSON

The patch runs against the YAML data model, so a few operations behave differently from a JSON patch library:

  • test compares YAML values. The integer 1 and the float 1.0 are different values, where RFC 6902 on JSON would call them equal.
  • Writes through an alias are refused, because they would change every place that shares the anchored node. Patch the anchor instead.
  • In Rust and C#, move and copy don’t carry comments. The value is written without the comments on its line; C# writes it in the style of its new place. To move an entry with its comments in Rust, use NodeMut::move_entry (edit YAML without losing comments).
  • A failed patch changes nothing. The error names the operation that failed: op_index() in Rust, OperationIndex in C#, and the error text in Go.

Each language lists every difference: Go patches, Rust patches and pointers and C# patches.

Merge patches for environment overrides

For overlays, such as a production file that changes a few values of a base file, a JSON merge patch (RFC 7396) is often simpler than a list of operations: objects merge key by key, null removes a key, and any other value, a list included, replaces what is there. The same documents apply it: ApplyMergePatch in Go and C#, apply_merge_patch in Rust, with the same layout-keeping result. Each language’s recipe shows an environment overlay: Go, Rust, C#.

JSON Patch, kubectl and kustomize

kubectl and kustomize also accept JSON Patch, but they apply it to Kubernetes objects: kubectl patch updates an API object in the cluster (Kubernetes docs), and kustomize patches resources as it builds them (kustomize example). Neither changes the YAML file in your repository. When the file itself is what you want to change, and a person will review the diff, patch the document as shown above.

To pick a library for your language, see the guides for Go, Rust and C#, or start from the oneyaml home page.