You sed’ed a YAML file and now it’s 2 AM
Somebody wrote sed -i 's/1.27/1.28/' compose.yml, it matched a port number on line 40, and the stack has been down since the deploy. YAML is whitespace with opinions, and regex has none of them. You need a tool that understands the structure.
That tool is yq, and you want the Go one: mikefarah/yq, currently v4.54.2. One Go binary, no runtime, edits files in place, and its syntax is close enough to jq that you already half know it. Skip the other yq, the Python wrapper around jq by kislyuk (v4.4.5). It is a fine tool, but it is a different tool with the same name, and mixing them up is an easy way to lose an afternoon. If you need the jq side first, start with these jq one-liners and come back.
Full example: Clone the working files at github.com/KingPin/sumguy-examples/devops/yq-editing-yaml-command-line
Every command and output below was run against mikefarah/yq:4.54.2 in Docker.
Two tools, one name
Run apt install yq on Debian or Ubuntu and you get the Python one. The Debian source package lists the kislyuk repository as its upstream, and as of November 2026 it ships 3.4.3 in trixie and in Ubuntu 25.04 and later, 3.1.0 in Ubuntu 24.04 LTS, and 4.4.5 in sid. Then you copy a command from a blog post, and nothing works.
| mikefarah/yq | kislyuk/yq | |
|---|---|---|
| Language | Go, single binary | Python, shells out to jq |
| Install | brew, snap, GitHub release, go install | pip install yq, distro packages |
| Needs jq installed | No | Yes |
| Output | YAML by default | JSON by default, -y for YAML |
| In-place edit | -i | -i together with -y or -Y |
| Comments | Kept by default | Dropped with -y, kept with -Y |
| Filter language | Its own jq-like dialect | Real jq |
Check which one you have:
yq --versionyq (https://github.com/mikefarah/yq/) version v4.54.2The Python one prints yq 4.4.5 with no URL. Here is the same read on both, using a compose file:
yq '.services.web.image' compose.ymlnginx:1.27The Python version, run on the same file, prints "nginx:1.27" with the JSON quotes, because it hands you jq’s raw output. Add -r for bare strings, or -y to get YAML back. For reads, the filters often look identical. For edits, the dialects drift apart, and so does the output format.
Install the right one:
# Linux, any distro: grab the release binarysudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/local/bin/yqsudo chmod +x /usr/local/bin/yq
# macOS or Linux with Homebrewbrew install yq
# Snapsnap install yq
# Arch (the plain "yq" package there is the Python one)pacman -S go-yq
# No install at alldocker run --rm -i -v "$PWD":/workdir mikefarah/yq:4.54.2 '.a.b' file.ymlThe Homebrew, snap, Arch and Alpine names come from the project README. Alpine 3.20 and later uses apk add yq-go. If you are in CI and want zero ambiguity, pin the binary from the GitHub release.
Reading values
The basics look like jq, without the quotes around strings:
yq '.services | keys' compose.ymlyq '.services[].image' compose.ymlyq '.services.web.ports[0]' compose.yml- web- db- cachenginx:1.27postgres:16.3redis:7.28080:80Strings come out bare, so you can drop them straight into a shell variable. A missing key prints null and exits 0, which will bite you in a script. The CI section below shows the fix.
You can also search by structure instead of path. This finds every image key at any depth:
yq '.. | select(has("image")) | .image' compose.ymlEditing, and the -i flag
Without -i, yq prints the edited document to stdout and leaves the file alone. Use that to review the change first:
yq '.services.web.image = "nginx:1.28"' compose.ymlWhen the diff looks right, add -i and the file changes in place. This is the forklift part of the tool: it moves things fast and it does not ask twice.
yq -i '.services.web.image = "nginx:1.28"' compose.ymlTwo compose chores come up constantly. Adding an environment variable depends on which style the service uses. List style takes +=:
yq '.services.web.environment += ["DEBUG=1"]' compose.yml web: <<: *defaults image: nginx:1.27 ports: - "8080:80" environment: - TZ=UTC - DEBUG=1Map style takes a plain assignment, and yq creates intermediate keys that do not exist yet:
yq '.services.db.environment.LOG_LEVEL = "debug"' compose.yml db: <<: *defaults image: postgres:16.3 environment: POSTGRES_USER: app POSTGRES_DB: app LOG_LEVEL: debugTo bump a tag on every service that uses one image family, use |= with sub:
yq '(.services.[] | select(.image | test("^postgres")) | .image) |= sub(":.*", ":17.1")' compose.ymlThat rewrites postgres:16.3 to postgres:17.1 and leaves the other services alone. The parentheses around the left side matter. They tell yq to update the matched nodes in place instead of returning only the matches.
Env vars: strenv vs env
Hard-coding values into a filter is how you end up with a script per environment. Read them from the environment instead. There are two functions, and they differ in one way that matters:
strenv(NAME)always gives you a string.env(NAME)parses the value as YAML, so9000becomes a number andtruebecomes a boolean.
export NEWTAG=1.28.0 PORT=9000yq '.services.web.image = "nginx:" + strenv(NEWTAG)' compose.ymlyq '.services.web.ports[0] = strenv(PORT) + ":80"' compose.ymlyq '.services.web.deploy.replicas = env(PORT)' compose.yml image: nginx:1.28.0 - "9000:80" replicas: 9000Use strenv when you build strings, and env when the target field needs a real number or boolean. If you use strenv for replicas, the file gets replicas: "9000", a quoted string, and a Kubernetes manifest wants an integer there.
Two gotchas live here. First, env(NAME) on an unset variable fails with Error: value for env variable 'NAME' not provided in env(), which is good. But strenv(NAME) on an unset variable quietly gives you an empty string, so a typo in the variable name writes "" into your config. Second, if you run the Docker image, the container cannot see your shell variables unless you pass them with -e NEWTAG -e PORT on the docker run line.
Kubernetes manifests: many documents, one file
A manifest with several --- documents is where yq earns its keep. A filter runs once per document. Select by kind:
yq 'select(.kind == "Deployment") | .metadata.name' k8s.ymlweb---workerSelect by position with document_index (or its short form di). The index counts from zero:
yq 'select(document_index == 1) | .kind' k8s.ymlServiceNow the trap. You want to set replicas to 3 on the Deployments. The obvious filter is this:
yq 'select(.kind == "Deployment") | .spec.replicas = 3' k8s.ymlIt prints only the two Deployments. The Service document is gone from the output. Add -i and the Service is gone from the file. I tested that: after the in-place run, yq '.kind' listed Deployment twice and nothing else. The fix is to wrap the selection and assignment in parentheses, so the update happens in place and every document survives:
yq '(select(.kind == "Deployment") | .spec.replicas) = 3' k8s.ymlkind: Deployment replicas: 3kind: Servicekind: Deployment replicas: 3(Output trimmed to the interesting lines.) The same pattern works for image bumps across every container in every Deployment:
yq -i '(select(.kind == "Deployment") | .spec.template.spec.containers[].image) |= sub(":1.4.0", ":1.5.0")' k8s.ymlRule of thumb: if the filter starts with select and you are editing a multi-document file, put the whole left side in parentheses.
Merging files
Config layering is the other big job. Use eval-all (or ea), which loads every file into one context, with * for a deep merge:
yq eval-all '. as $item ireduce ({}; . * $item)' base.yml override.ymlapp: name: demo log_level: debug features: - metricsdb: host: db.internal port: 5432Maps merge key by key, and later files win. Look at features, though. The base file had auth and search, the override had metrics, and the result has only metrics. Plain * replaces arrays. If you want them appended, use *+:
yq eval-all 'select(fileIndex == 0) *+ select(fileIndex == 1)' base.yml override.yml features: - auth - search - metricsThe ireduce form scales to any number of files, which is handy for base.yml, prod.yml, secrets.yml stacks. For exactly two files, yq '. * load("override.yml")' base.yml does the same job without eval-all.
Deleting keys
del takes a path or a selection:
yq 'del(.app.features)' base.ymlyq 'del(.services.cache)' compose.ymlyq 'del(.app.features[0])' base.ymlThe last one removes auth and keeps search. Wildcards work, which brings us to anchors.
What yq does to your comments and formatting
This is the part to read before you point -i at a hand-tuned file. I ran a small file through yq '.version = 2':
# top commentname: demo # trailing comment
version: 1list: - a # item a # before b - b# top commentname: demo # trailing commentversion: 2list: - a # item a # before b - bWhat survived, and what did not:
- Kept: the head comment, trailing comments, and comments between list items.
- Normalized: the three spaces before
# trailing commentbecame one. Indentation resets to two spaces unless you pass-I 4. - Lost: blank lines. Every blank line in the file is gone, including the ones that separated sections. This held even for a plain
yq .that changed nothing, and I saw it in 4.54.2 on every file I tried. - Kept: quote style.
"8080:80"stays quoted.
If your diff reviewers care about blank lines, expect noisy commits the first time you run yq on a file. Run yq -i '.' once as a formatting-only commit, then make the real edits after.
Anchors and merge keys also need care. This compose file starts with x-defaults: &defaults and every service uses <<: *defaults. Editing through the alias is fine: yq '.services.web.restart' compose.yml follows the merge and prints unless-stopped. But a wildcard delete follows the same pointer:
yq 'del(.services.*.restart)' compose.yml# Media stackx-defaults: &defaults {}The delete reached through the merge key and emptied the anchor itself. Delete from the anchor on purpose, or not at all. If you want to flatten everything first, yq 'explode(.)' file.yml resolves every alias and merge key into plain values.
You will also see a warning on stderr when yq traverses a merge key: --yaml-fix-merge-anchor-to-spec is false; causing merge anchors to override the existing values which isn't to the yaml spec. In 4.54.2 the flag still defaults to false, even though the message says it would flip in late 2025. Pass --yaml-fix-merge-anchor-to-spec to opt in to the spec behavior, and diff your files after.
YAML to JSON and back
-o sets the output format, -p sets the input format:
yq -o json -I=0 base.yml{"app":{"name":"demo","log_level":"info","features":["auth","search"]},"db":{"host":"localhost","port":5432}}-I=0 gives compact one-line JSON. Drop it for pretty output. Going back uses -p json:
echo '{"a": 1, "b": [true, null]}' | yq -p jsona: 1b: - true - nullyq also guesses from the file extension. Run yq . x.json on a .json file and you get JSON out, not YAML. Other output formats include csv, props, xml, toml and more; run yq --help for the list in your version.
Markdown front matter
Static-site people, this one is for you. --front-matter=process edits the YAML header and passes the body through untouched:
yq --front-matter=process '.draft = false' post.md---title: Hellotags: - onedraft: false---
# Body
Some text here.Add -i to flip a hundred posts from draft to published in a loop. --front-matter=extract prints just the header, handy for yq --front-matter=extract '.title' post.md.
Using yq in a script or CI job
Two habits keep a pipeline honest. First, remember that a missing key is null with exit code 0. Add -e (--exit-status) and the exit code becomes 1:
yq -e '.services.nope.image' compose.ymlecho $?null1Second, pin the version. A CI job that downloads “latest” will eventually hit a release that changes behavior. The merge-anchor flag above is exactly that kind of change. The binary URL takes a version tag, and so does the Docker image.
A small deploy step that ties it together:
#!/usr/bin/env bashset -euo pipefailexport NEWTAG="${1:?usage: bump.sh <tag>}"
yq -e '.services.web.image' compose.yml >/dev/null # fail early if the key is goneyq -i '.services.web.image = "nginx:" + strenv(NEWTAG)' compose.ymlyq '.services[].image' compose.yml # print the result into the CI logIf you run yq through Docker in CI, add --user "$(id -u):$(id -g)" so -i writes files as you. The image runs as a non-root user, so the mounted files need to be world-writable or the container needs your UID.
The SumGuy Take
Install the Go binary, pin its version, and do a dry run without -i before every edit you care about. Put the whole left side in parentheses when select meets an assignment. Keep the Python yq around only if you already live in jq and want its full language, with -Y to keep your comments.
For everything else, yq does in one line what used to take a fragile sed and a prayer. Your 2 AM self will thank you.
Common Questions
What is the difference between yq and jq?
jq processes JSON only. The Go yq reads and writes YAML, JSON, XML, CSV and more, with a jq-like syntax of its own. The Python yq converts YAML to JSON and pipes the result to jq, so it needs jq installed. Choose Go yq for YAML files you plan to edit.
Why does apt install the wrong yq on Ubuntu or Debian?
The yq package in Debian and Ubuntu is the Python wrapper by kislyuk, not the Go binary by mikefarah. The names collide. Install the Go version from a GitHub release, Homebrew, or snap instead, and check with yq --version.
Does yq keep comments when it edits a YAML file?
Yes for comments, no for blank lines. The Go yq 4.54.2 kept head, trailing and between-item comments in testing, but it removed every blank line and reset indentation to two spaces. The Python yq drops comments with -y and keeps them only with -Y.
How do I edit a file in place with yq?
Add -i to the Go yq: yq -i '.a.b = "x"' file.yml. Without -i, yq prints to stdout and leaves the file unchanged. The Python yq needs -i together with -y or -Y to write YAML back. Run without -i first to review the diff.