A CLI filter for updating YAML documents from one or more YAML/JSON data files while preserving comments, key order and formatting of the original.
Tailored for developers who maintain hand-edited config files, Kubernetes manifests, Helm values, CI/CD configs, or any YAML file where comments and structure matter and a full re-marshal would lose them.
While round-tripping YAML through generic tools (yq, jaq, ad-hoc scripts) is easy, doing it without destroying
comments, blank lines and the author's intent is not. That's why it is important to separate 2 concerns for this task:
update YAML file and calculate data for update. When these 2 concerns are separated any tool or any language can be used
to prepare a new data set and what is left for update-yaml is just take the provided data and inject it into correct
place into existing YAML keeping it as close as possible to the original document.
# source from STDIN, single data file
update-yaml <<< 'name: old # some name' <(echo 'name: new')# source from STDIN, multiple data files merged in order (later wins)
update-yaml < source.yaml base.yaml override.yaml- -h, --help Display help message
- -m, --man Display full readme (tip: update-yaml --man | colored-md)
- -v, --version Display version information (tip: update-yaml --version | jq -r .Version)
UPDATE_YAML_PREFER_ORDER_PRESERVED- when set, new keys are appended at the end of their mapping in the order declared by data files, instead of being spliced in alphabetically among the existing keys (the default).UPDATE_YAML_PREFER_SINGLE_QUOTE- when set, values that need quoting are emitted with single quotes regardless of what the source file uses. Plain strings stay plain - quotes are never added to values that don't need them, and existing quoted values keep their original style on replace.
go install github.com/andrew-grechkin/update-yaml@latestBy default, go install creates binaries in $GOBIN or $GOPATH/bin.
To make sure you can use the installed binary you need to add this directory to your path.
# ensure the go install binaries are in your PATH, consider adding to your shell startup config
export PATH="${GOBIN:-${GOPATH:-$HOME/go}/bin}:$PATH"- Pure CLI filter: reads source YAML from STDIN, writes updated result to STDOUT
- Preserves head, inline and trailing comments on untouched and replaced values
- Preserves key order of the original document
- Auto-detects and preserves indent width (block mappings) and sequence indent style (flush vs. extra-indented)
- Auto-detects single vs. double quote preference from the source for new emissions; replaced values keep their original quote style when meaningful, or drop quotes the new value doesn't need
- Deep merges multiple data files (like Helm) with first-occurrence-wins ordering: later files override values, but new keys are appended at the end while existing keys keep their slot
- Explicit
nullin data removes the corresponding key from the output - Anchors (
&name) and aliases (*name) are preserved - Multi-document YAML supported on both sides (STDIN doc[i] is updated by merged data doc[i])
Update with inline data:
update-yaml <<< 'name: old # some name' <(echo 'name: new')Or with actual files:
update-yaml < source.yaml data.yamlOr UUOC, if one would like:
cat source.yaml | update-yaml data.yamlupdate-yaml << EO_INPUT <(echo 'replicas: 5') <(echo 'config: {debug: true, cache: enabled}')
name: svc
# one replica is enough
replicas: 1
config:
timeout: 30 # timeout is required
debug: false
EO_INPUTOr with actual files:
update-yaml < source.yaml base.yaml override.yaml override2.yamlLater files override earlier ones (just like with helm install -f base.yaml -f override.yaml).
Given a source like:
# Database connection settings
database:
host: localhost # default for dev
port: 5432And data:
database:
host: db.internalThe result keeps both the head comment and the inline comment and only the value changes:
# Database connection settings
database:
host: db.internal # default for dev
port: 5432Setting a key to null in the merged data removes it from the output:
update-yaml << EO_INPUT <(echo 'debug: null')
---
name: svc # this is a name of a service
# debugging disabled
debug: false
EO_INPUTThis works at any depth and survives merging - a later data file can null out a key set by an earlier one.
Keys present in data but absent from the source are spliced in among the existing keys at the position they belong alphabetically. When the source mapping is empty (just being created), keys are appended in the order data declares them, since there is no existing order to fit into:
update-yaml <<< $'name: svc\ndescription: desc' <(echo $'version: 1.0\ndescription: my service')Set UPDATE_YAML_PREFER_ORDER_PRESERVED to switch to "append in data order" for non-empty mappings as well.
YAML allows two block-sequence styles: items flush with the parent key, or indented one level deeper. The first style the source uses (per file) is auto-detected and applied to existing sequences when their value is replaced. New sequences (added as part of a freshly-appended key) use the indented form, which is the more readable default.
# flush style - preserved on replace
items:
- a
- b
# indented style - also preserved on replace
items:
- a
- bThe first explicitly-quoted string in the source decides the preference for newly-emitted values that need quoting (numbers-as-strings, looks-like-booleans, etc.). Plain strings stay plain - quotes are never added to values that don't need them. When replacing a quoted value with another value that also needs quoting, the original quote style is preserved; if the new value can render plain, it does, dropping unnecessary quotes the source had.
Set UPDATE_YAML_PREFER_SINGLE_QUOTE to skip detection and always prefer single quotes.
Empty STDIN is a valid YAML stream containing zero documents. When data is provided, the data becomes the result. A
single --- marker on STDIN counts as one empty document; data is injected into it and the leading marker is kept.
A {} flow-style mapping likewise accepts data, and the flow style is preserved in the output.
update-yaml <<< '' <(echo 'foo: bar') # → foo: bar
update-yaml <<< '---' <(echo 'foo: bar') # → ---\nfoo: bar
update-yaml <<< '{}' <(echo 'foo: bar') # → {foo: bar}If the source STDIN contains multiple YAML documents (--- separated), each is updated independently by the merged
data document at the same index. When data files are provided they must cover every STDIN doc; fewer data docs than
STDIN docs is an error. Extra data docs (beyond STDIN's count) are ignored.
update-yaml << EO_INPUT <(echo $'{"debug": null}\n---\n{"size": "micro","service": {"enabled": false}}')
---
name: svc
debug: false
---
service:
name: svc
enabled: true
EO_INPUTA data file may target a non-first STDIN doc by including explicit empty placeholder docs at the front, in canonical
YAML 1.2 stream form. Use {} (empty mapping) or null as the placeholder body for any doc that should not contribute
updates. Trailing ... end markers are supported but optional.
The most compact form uses an inline placeholder:
--- {}
---
real: updates for the second doc in the inputRepeat the --- {} line to skip more leading docs. Files emitted by tools that produce canonical streams (e.g. jaq --to yaml or yq -y) also work as-is:
---
{}
...
---
real: updates for the second doc in the input
...- 0: Success
- 1: Parse errors, invalid input, doc count mismatch, or other runtime errors
github.com/goccy/go-yaml is FULL of bugs and poorly maintained
One can see that there are dozens of pull requests fixing bugs in the library. Maintainers seem just ignore them and nothing is being fixed for a long time.
This is a bitter irony because author claimed one of the reasons for this library to exist is poorly maintained go-yaml/yaml.
I'm trying to workaround some of the bugs in my code, but of course something can slip in.
The underlying YAML library (github.com/goccy/go-yaml) parses ---\n---\n as a single document with implicit-null
body, not as two empty documents. If you need N empty documents in a stream, write them with explicit null (or empty
mapping) bodies:
--- null
--- nullWhen data is provided for an index which is an explicit null or implicit-null ---\n body is treated as an empty
mapping so the data can land in it. Slots with no data leave the null body intact, so unmodified placeholder docs
survive.
When a value uses YAML's folded-style block scalar (>, with or without +/- chomping indicators), and that key is
modified by data, the output uses the literal style (|) instead.
Reason: the underlying YAML library (github.com/goccy/go-yaml) exposes a UseLiteralStyleIfMultiline marshalling
option but no folded-style analogue, so multi-line strings always render as literal blocks. Untouched docs pass through
verbatim and are not affected; only the replaced value loses its folded style.
If preserving > matters for a specific key, leave it untouched and put any related changes on different keys.
- Andrew Grechkin
This project is licensed under the GNU General Public License Version 2 (GPLv2).
See the LICENSE file for details.