Skip to content
Go back

yq: Editing YAML From the Command Line

By KingPin 13 min read
yq: Editing YAML From the Command Line
Contents

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/yqkislyuk/yq
LanguageGo, single binaryPython, shells out to jq
Installbrew, snap, GitHub release, go installpip install yq, distro packages
Needs jq installedNoYes
OutputYAML by defaultJSON by default, -y for YAML
In-place edit-i-i together with -y or -Y
CommentsKept by defaultDropped with -y, kept with -Y
Filter languageIts own jq-like dialectReal jq

Check which one you have:

Terminal window
yq --version
yq (https://github.com/mikefarah/yq/) version v4.54.2

The Python one prints yq 4.4.5 with no URL. Here is the same read on both, using a compose file:

Terminal window
yq '.services.web.image' compose.yml
nginx:1.27

The 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:

Terminal window
# Linux, any distro: grab the release binary
sudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/local/bin/yq
sudo chmod +x /usr/local/bin/yq
# macOS or Linux with Homebrew
brew install yq
# Snap
snap install yq
# Arch (the plain "yq" package there is the Python one)
pacman -S go-yq
# No install at all
docker run --rm -i -v "$PWD":/workdir mikefarah/yq:4.54.2 '.a.b' file.yml

The 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:

Terminal window
yq '.services | keys' compose.yml
yq '.services[].image' compose.yml
yq '.services.web.ports[0]' compose.yml
- web
- db
- cache
nginx:1.27
postgres:16.3
redis:7.2
8080:80

Strings 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:

Terminal window
yq '.. | select(has("image")) | .image' compose.yml

Editing, 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:

Terminal window
yq '.services.web.image = "nginx:1.28"' compose.yml

When 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.

Terminal window
yq -i '.services.web.image = "nginx:1.28"' compose.yml

Two compose chores come up constantly. Adding an environment variable depends on which style the service uses. List style takes +=:

Terminal window
yq '.services.web.environment += ["DEBUG=1"]' compose.yml
output (web service)
web:
<<: *defaults
image: nginx:1.27
ports:
- "8080:80"
environment:
- TZ=UTC
- DEBUG=1

Map style takes a plain assignment, and yq creates intermediate keys that do not exist yet:

Terminal window
yq '.services.db.environment.LOG_LEVEL = "debug"' compose.yml
output (db service)
db:
<<: *defaults
image: postgres:16.3
environment:
POSTGRES_USER: app
POSTGRES_DB: app
LOG_LEVEL: debug

To bump a tag on every service that uses one image family, use |= with sub:

Terminal window
yq '(.services.[] | select(.image | test("^postgres")) | .image) |= sub(":.*", ":17.1")' compose.yml

That 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:

Terminal window
export NEWTAG=1.28.0 PORT=9000
yq '.services.web.image = "nginx:" + strenv(NEWTAG)' compose.yml
yq '.services.web.ports[0] = strenv(PORT) + ":80"' compose.yml
yq '.services.web.deploy.replicas = env(PORT)' compose.yml
image: nginx:1.28.0
- "9000:80"
replicas: 9000

Use 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:

Terminal window
yq 'select(.kind == "Deployment") | .metadata.name' k8s.yml
web
---
worker

Select by position with document_index (or its short form di). The index counts from zero:

Terminal window
yq 'select(document_index == 1) | .kind' k8s.yml
Service

Now the trap. You want to set replicas to 3 on the Deployments. The obvious filter is this:

Terminal window
yq 'select(.kind == "Deployment") | .spec.replicas = 3' k8s.yml

It 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:

Terminal window
yq '(select(.kind == "Deployment") | .spec.replicas) = 3' k8s.yml
kind: Deployment
replicas: 3
kind: Service
kind: Deployment
replicas: 3

(Output trimmed to the interesting lines.) The same pattern works for image bumps across every container in every Deployment:

Terminal window
yq -i '(select(.kind == "Deployment") | .spec.template.spec.containers[].image) |= sub(":1.4.0", ":1.5.0")' k8s.yml

Rule 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:

Terminal window
yq eval-all '. as $item ireduce ({}; . * $item)' base.yml override.yml
output
app:
name: demo
log_level: debug
features:
- metrics
db:
host: db.internal
port: 5432

Maps 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 *+:

Terminal window
yq eval-all 'select(fileIndex == 0) *+ select(fileIndex == 1)' base.yml override.yml
output (app.features)
features:
- auth
- search
- metrics

The 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:

Terminal window
yq 'del(.app.features)' base.yml
yq 'del(.services.cache)' compose.yml
yq 'del(.app.features[0])' base.yml

The 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':

comments.yml (before)
# top comment
name: demo # trailing comment
version: 1
list:
- a # item a
# before b
- b
after
# top comment
name: demo # trailing comment
version: 2
list:
- a # item a
# before b
- b

What survived, and what did not:

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:

Terminal window
yq 'del(.services.*.restart)' compose.yml
# Media stack
x-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:

Terminal window
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:

Terminal window
echo '{"a": 1, "b": [true, null]}' | yq -p json
a: 1
b:
- true
- null

yq 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:

Terminal window
yq --front-matter=process '.draft = false' post.md
---
title: Hello
tags:
- one
draft: 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:

Terminal window
yq -e '.services.nope.image' compose.yml
echo $?
null
1

Second, 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:

Terminal window
#!/usr/bin/env bash
set -euo pipefail
export NEWTAG="${1:?usage: bump.sh <tag>}"
yq -e '.services.web.image' compose.yml >/dev/null # fail early if the key is gone
yq -i '.services.web.image = "nginx:" + strenv(NEWTAG)' compose.yml
yq '.services[].image' compose.yml # print the result into the CI log

If 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
polkit Rules: Restart a Service, No sudo
Next Post
Security Headers That Break Your Apps

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts