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 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:
[ { "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:
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)) }
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
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) }
# 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
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(()) }
# 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#
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}"); }
# 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:
testcompares YAML values. The integer1and the float1.0are 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#,
moveandcopydon’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, useNodeMut::move_entry(edit YAML without losing comments). - A failed patch changes nothing. The error names the operation that failed:
op_index()in Rust,OperationIndexin 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.