A how-to manual: which yq you have, the filter language, in-place edits that keep comments, multi-document files, merging, format conversion, scripts without surprises, errors and speed, with every example run for real.
▶Prefer to watch? This short video runs this page's main commands on a real Ubuntu 24.04 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
yq: a command-line processor for YAML, like jq is for JSON
yq '.services.app.image = "shop:2.5.0"' -i compose.yaml
yq '.users[] | select(.active) | .name' config.yaml
YAML is the configuration language of modern Linux work: Docker Compose, Kubernetes, Ansible, netplan, cloud-init, GitHub Actions, Prometheus. Editing it with sed is fragile, because indentation is the structure. yq reads the file as data, applies a small filter, and writes it back. It also reads and writes JSON, XML, TOML, CSV, TSV, INI and Java properties, so it doubles as a converter.
If you know jq, the filters look familiar, and that is on purpose. But there is a trap before you type anything: two different programs are called yq, and Ubuntu's package is not the popular one. The first section shows how to tell them apart. This guide describes the Go program by Mike Farah (github.com/mikefarah/yq), version 4, which is the one most documentation, CI examples and blog posts mean.
Every example below ran for real, on Ubuntu 24.04, with yq v4.54.1 (released 2026-09-29) against the sample files shown in the next section. Where something is version-dependent, or the Python yq behaves differently, it is marked.
Which yq? Install, and the sample data
There are two programs named yq, with different filters, flags and output:
mikefarah/yq a single Go binary, its own YAML-aware language, edits files in place and keeps comments. This is the one in this guide.
kislyuk/yq a Python wrapper around jq: it turns YAML into JSON, runs jq, and turns the result back. It uses jq's language, not the one below.
Ubuntu 24.04's package named yq is the Python one:
$ apt-cache policy yq | grep Candidate
Candidate: 3.1.0-3
$ sudo apt install yq
$ yq --version
yq 0.0.0 (the package really prints this)
$ dpkg -s yq | grep ^Depends
Depends: jq, python3-argcomplete, python3-toml, python3-xmltodict, python3-yaml, python3:any
If a script or tutorial says yq '.a.b' file.yaml and gets odd results, check which one you have. For the Go program, --version names its web address:
$ yq --version
yq (https://github.com/mikefarah/yq/) version v4.54.1
The Go program is not in Ubuntu's repositories. The simplest install is the release binary from GitHub, after checking the checksum. The release publishes a file called checksums with many hash columns; the SHA-256 is the 19th field on the line (the order is listed in checksums_hashes_order):
$ B=https://github.com/mikefarah/yq/releases/download/v4.54.1
$ curl -sLO $B/yq_linux_amd64 -O $B/checksums
$ grep '^yq_linux_amd64 ' checksums | awk '{print $19}'
8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f
$ sha256sum yq_linux_amd64
8e34fc298390875de416e6a4afcb8cabeceb25d9aa8506c1a2f9353cf702ea5f yq_linux_amd64
$ sudo install -m755 yq_linux_amd64 /usr/local/bin/yq
The two hashes matched in the lab. The release also has a man page archive (yq_man_page_only.tar.gz, containing yq.1 and an install script) and the checksums have signature bundles; the man page install and the signature check were not run. Other routes (the snap, Homebrew, the Docker image, go install) exist but were not run in the lab.
If you install the Go program and keep Ubuntu's yq, /usr/local/bin comes first in PATH, so yq means the Go one and the Python one is still at /usr/bin/yq. Better still, remove the apt package so nobody runs the wrong one.
The examples use these files. config.yaml is an application config with comments, nesting, a list of strings and a list of small maps:
$ cat config.yaml
# Application settings
app:
name: shop # the service name
version: 2.4.1
debug: false
port: 8080
server:
host: 0.0.0.0
tls:
enabled: true
cert: /etc/ssl/shop.pem
db:
host: db1.internal
port: 5432
pool: 10
features:
- search
- checkout
- reviews
users:
- {name: ann, role: admin, active: true}
- {name: ed, role: dev, active: true}
- {name: kim, role: dev, active: false}
compose.yaml is a small Docker Compose file (web, app and db services, one volume); multi.yaml is a Kubernetes-style file with three documents (a ConfigMap, a Deployment with two containers, and a Service) separated by ---. They are shown where they are used.
First steps: reading values
The expression is the first argument and the file is the second. A dot is the whole document. A name after a dot reads a key:
$ yq .app.name config.yaml
shop
$ yq .app config.yaml
name: shop # the service name
version: 2.4.1
debug: false
port: 8080
$ yq '.features[1]' config.yaml
checkout
$ yq '.users[] | select(.active) | .name' config.yaml
ann
ed
$ yq '.services | keys' compose.yaml
- web
- app
- db
Notes on the output. The result is YAML by default (the file is YAML), so a block is printed as a block and a comment that belongs to it comes along (see "name: shop # the service name"). A single string, number or boolean is printed bare, with no quotes: that is the right form for a shell variable, and there is no -r to remember (the option exists, and it is already on for YAML).
Array elements are numbered from 0, a negative number counts from the end, [] with nothing inside means every element, and a pipe feeds the output of one stage into the next, exactly like jq:
$ yq '[.users[].name] | .[-1]' config.yaml
kim
$ yq '.users[1:] | length' config.yaml (a slice: from the second element on)
2
$ yq '.services.web.ports' compose.yaml
- "80:80"
A missing key is not an error. It reads as null, and you can give a default with //:
$ yq .missing.deep config.yaml
null
$ yq '.missing // "dflt"' config.yaml
dflt
Be careful with a value that is not a map. Asking a string for a key prints nothing, and asking a list for a name is an error:
$ yq .app.name.x config.yaml (nothing printed, exit status 0)
$ yq .users.name config.yaml
Error: cannot index array with 'name' (strconv.ParseInt: parsing "name": invalid syntax)
To list every path in a document, with the value of the type test:
$ yq '[.. | path | join(".")] | .[0:5]' -o json -I0 config.yaml
["","app","app.name","app.version","app.debug"]
The two dots (..) mean every node, recursively; path gives the way to it as a list. This is how you find where a setting lives in a big file you did not write.
Options and output formats
yq decides the input and output format from the file name (.yaml, .yml, .json, .xml and so on) and falls back to YAML for standard input and unknown names. The flags you will use:
-i, --inplace edit the first file in place (see the warning about several files below)
-o, --output-format yaml (y), json (j), props (p), csv (c), tsv (t), xml (x), toml, shell (s), base64, uri, ini, lua, kyaml
-p, --input-format the same list, for input; needed for standard input that is not YAML
-I, --indent N spaces per level (default 2); -I0 with -o json gives one compact line
-P, --prettyPrint print YAML in the idiomatic block style (turns {a: 1} flow style into blocks)
-N, --no-doc do not print the --- separators between documents
-n, --null-input do not read a file: build a document from the expression alone
-e, --exit-status exit 1 when the result is null, false or empty (like jq -e)
-s, --split-exp EXP write each result to its own file, named by an expression
-C, -M force colours on, or off
-0, --nul-output separate results with a NUL for xargs -0
--from-file FILE read the expression from a file
--expression EXP say that this argument is the expression, not a file name
eval-all (ea) load all documents of all files together, run the expression once
Two sub-commands exist: eval (the default, and the e you see in old examples) and eval-all. Plain yq expr file is the same as yq eval expr file.
Format conversion is just -o. JSON is also valid YAML, so a JSON file reads fine; the default output follows the extension:
$ yq -o json '.app' config.yaml
{
"name": "shop",
"version": "2.4.1",
"debug": false,
"port": 8080
}
$ yq -o json -I0 '.app' config.yaml
{"name":"shop","version":"2.4.1","debug":false,"port":8080}
$ echo '{"a": {"b": [1, 2]}}' > t.json
$ yq .a t.json (a .json file gives JSON back)
{
"b": [
1,
2
]
}
$ yq .a -o yaml t.json
b:
- 1
- 2
Standard input has no file name, so it is read as YAML and written as YAML. A JSON document comes out in YAML's compact "flow" style unless you say what you want:
$ echo '{"a":1}' | yq .a
1
$ echo '{"a":1}' | yq .
{"a": 1}
$ echo '{"a":1}' | yq -o json .
{
"a": 1
}
$ echo '{"a":1}' | yq -P . (pretty print: block style)
a: 1
Careful with -p json plus no -o: it prints a warning, because the default output changed to "auto" (by file extension) and for stdin that means YAML:
$ echo '{"a": [1, 2]}' | yq -p json
time=... level=WARN msg="yq default output is now 'auto' (based on the filename extension). ... Please use -oy to specify yaml, or drop the -p flag."
a:
- 1
- 2
Say both when you pipe: yq -p json -o yaml, or yq -p json -o json.
Selecting: select, comparisons and wildcards
select(condition) passes a node on only when the condition is true, and it is the main filter. It is usually applied to every element of a list or map with [] or *:
$ yq '.users[] | select(.role == "dev") | .name' config.yaml
ed
kim
$ yq '.users[] | select(.name | test("^(ann|kim)")) | .name' config.yaml
ann
kim
$ yq '.users | map(select(.role == "dev")) | length' config.yaml
2
$ yq '.services.* | select(has("ports")) | path | .[-1]' compose.yaml (which services publish ports)
web
Comparisons are looser than in jq. The == operator compares the text of the values, so a number equals its string, and a boolean equals "false":
$ yq '.app.port == 8080' config.yaml
true
$ yq '.app.port == "8080"' config.yaml
true
$ yq '.app.debug == "false"' config.yaml
true
$ yq '.app.port > 9000' config.yaml
false
$ yq '.app.port > "100"' config.yaml (ordering a number against a string is an error)
Error: !!int not yet supported for comparison
Every node has a tag that says what type YAML thinks it is (!!str, !!int, !!float, !!bool, !!null, !!map, !!seq, !!timestamp). You can ask and select by it:
$ yq '.app.name | tag' config.yaml
!!str
$ yq '.app.port | tag' config.yaml
!!int
$ yq '[.. | select(tag == "!!int")]' -o json -I0 config.yaml
[8080,5432,10]
$ yq '[.. | select(tag == "!!bool")] | length' config.yaml
5
Recursive search is where yq beats grep for structure. These found every node by a property, and the parent of a match:
$ yq '.. | select(has("role")) | .name' config.yaml (every map that has a role key)
ann
ed
kim
$ yq '[.. | select(. == "dev") | parent | .name]' -o json -I0 config.yaml
["ed","kim"]
$ yq '[.. | select(tag == "!!str" and test("shop")) | path | join(".")]' -o json -I0 config.yaml
["app.name","server.tls.cert"]
Defaults and alternatives work as in jq: a // b gives b when a is null or false. Booleans combine with and, or and not:
$ yq '.app.timeout // 30' config.yaml
30
$ yq '.users | all_c(.active), any_c(.active)' config.yaml
false
true
Functions to know: length, keys, has("k"), contains, test("regex"), sort_by(.k), group_by(.k), unique, unique_by(.k), reverse, flatten, min, max, to_entries, with_entries, pick, omit, parent, key, path, document_index (di), file_index (fi), filename and line. Several jq functions do not exist in yq; they are collected in the version section.
Building new data and arrays
-n runs the expression with no input file, which makes yq a way to generate YAML or JSON from scratch. Assigning to a path creates the path, and assigning to a high array index pads with nulls:
$ yq -n '.a = "x" | .b.c = [1,2] | .d[2] = "z"'
a: x
b:
c:
- 1
- 2
d:
- null
- null
- z
$ yq -n '{"a": 1, "b": [1,2]}' -o json -I0
{"a":1,"b":[1,2]}
Map and array building inside a filter. These produce new data from the old:
$ yq '.users | map(.name)' config.yaml -o json -I0
["ann","ed","kim"]
$ yq '.users | group_by(.role) | map({"role": .[0].role, "n": length})' config.yaml -o json -I0
[{"role":"admin","n":1},{"role":"dev","n":2}]
$ yq '[.users[].role] | unique' config.yaml -o json -I0
["admin","dev"]
$ yq '[.features, ["x"]] | flatten' config.yaml -o json -I0
["search","checkout","reviews","x"]
$ yq '.app | to_entries | .[0]' config.yaml -o json -I0
{"key":"name","value":"shop"}
$ yq '.db | with_entries(select(.key != "pool")) | keys' config.yaml -o json -I0
["host","port"]
$ yq '.app | pick(["name"])' config.yaml -o json -I0
{"name":"shop"}
$ yq '[.users[] | .name + "@example.com"]' config.yaml -o json -I0
["ann@example.com","ed@example.com","kim@example.com"]
Adding with + works on strings (join), arrays (append) and maps (merge, right side wins):
$ yq -n '"a" | . + "b"'
ab
$ yq -n -o json -I0 '[1,2] + [3]'
[1,2,3]
$ yq -n -o json -I0 '{"a":1} + {"b":2}'
{"a":1,"b":2}
There is no add or sum function in v4.54.1 (the lab got a parse error on [1,2,3] | add), and no first, last or range. Sum with a reduce, and use indexes for first and last:
$ yq -n '[1,2,3] | .[] as $i ireduce (0; . + $i)'
6
$ yq '[.users[].name] | .[0], .[-1]' config.yaml
ann
kim
Text for other programs is a join, or one of the output formats:
$ yq '[.users[] | select(.role == "dev") | .name] | join(" ")' config.yaml
ed kim
$ yq '.users[] | [.name, .role] | @csv' config.yaml
ann,admin
ed,dev
kim,dev
$ yq -o tsv '.users' config.yaml
name role active
ann admin true
ed dev true
kim dev false
Updating, adding and deleting
This is why most people install yq. An assignment changes a value and leaves the rest of the document, comments included, as it was. Without -i the result goes to the screen and the file is untouched:
$ yq '.app.version = "2.5.0"' config.yaml | head -6
# Application settings
app:
name: shop # the service name
version: 2.5.0
debug: false
port: 8080
The assignment operators:
= set the value (the right side is evaluated against the document root)
|= update: the right side is evaluated against the value being replaced, so . means the old value
+= add to it (append to a list, add a number, join a string)
-= subtract *= merge //= is NOT available (use |= (. // default))
del(path) remove a key or an element
$ yq '.db.pool = 20 | .app.debug = true' config.yaml
$ yq '.features += ["wishlist"] | .features' config.yaml
- search
- checkout
- reviews
- wishlist
$ yq 'del(.features[0]) | .features' config.yaml
- checkout
- reviews
$ yq 'del(.users[] | select(.active == false)) | .users' config.yaml
- {name: ann, role: admin, active: true}
- {name: ed, role: dev, active: true}
$ yq '.users[] |= (.team = "core") | .users[0]' config.yaml
{name: ann, role: admin, active: true, team: core}
$ yq '(.users[] | select(.name == "kim") | .active) = true | .users' config.yaml
- {name: ann, role: admin, active: true}
- {name: ed, role: dev, active: true}
- {name: kim, role: dev, active: true}
$ yq '.app.timeout |= (. // 30) | .app.timeout' config.yaml
30
$ yq '.services.app.deploy.replicas |= . + 1 | .services.app.deploy' compose.yaml
replicas: 3
Put parentheses around a path that contains a select, as in the kim example: without them the | binds the wrong way and you edit a copy.
To change the file itself, add -i. It rewrites the file and prints nothing. It keeps the file's permissions (a 640 file stayed 640), and when you name a symbolic link it edits the target and keeps the link:
$ chmod 640 config.yaml; ln -s config.yaml link.yaml
$ yq -i '.app.port = 2' link.yaml
$ ls -l config.yaml link.yaml
-rw-r----- 1 ubuntu ubuntu 406 Oct 17 13:17 config.yaml
lrwxrwxrwx 1 ubuntu ubuntu 11 Oct 17 13:17 link.yaml -> config.yaml
Always diff before you trust a bulk edit. Take a copy, edit, and look:
$ cp config.yaml c.bak
$ yq -i '.app.version = "2.5.0"' config.yaml
$ diff c.bak config.yaml
3,4c3,4
< name: shop # the service name
< version: 2.4.1
---
> name: shop # the service name
> version: 2.5.0
The comment survived, but its alignment did not: yq writes one space before a trailing comment. The next section lists what the round trip preserves and what it does not.
DANGER: -i with several files. The lab edited two files and the result was not what the command suggests. Only the first file is rewritten, and it also receives the content of the second file as an extra document:
$ cp c.bak d/a.yaml; cp c.bak d/b.yaml
$ yq -i '.app.debug = true' d/a.yaml d/b.yaml
$ wc -l d/a.yaml d/b.yaml
47 d/a.yaml (the file doubled: two documents)
23 d/b.yaml (untouched, debug is still false)
Edit many files one at a time, with a loop or find. Both gave the right result in the lab:
$ for f in d/*.yaml; do yq -i '.app.debug = true' "$f"; done
$ find d -name '*.yaml' -exec yq -i '.app.version = "7"' {} \;
Comments, quotes and what a round trip keeps
yq keeps comments, which is the thing jq-based tools and PyYAML scripts lose. The Python yq, run with -i in the lab, dropped the leading comment of config.yaml; the Go program kept it:
$ yq '.app.name | line_comment' config.yaml
the service name
$ yq 'head_comment' config.yaml
Application settings
$ yq '.app.port line_comment="http port" | .app' config.yaml | head -6
name: shop # the service name
version: 2.4.1
debug: false
port: 8080 # http port
$ yq '.server head_comment="network" | .server' config.yaml | head -3
# network
host: 0.0.0.0
tls:
The comment functions are head_comment, line_comment and foot_comment, and ... comments="" removes them all.
What changes when yq rewrites a file (all of these were seen in the lab):
1. The space before a trailing comment is reduced to one.
2. A folded block scalar (key: >) is rewritten with its text on one line and a blank line after it. The text is the same value, but a diff shows noise:
$ cat blk.yaml
script: |
echo one
echo two
folded: >
long text
continues here
$ yq . blk.yaml
script: |
echo one
echo two
folded: >
long text continues here
3. Output is indented by the -I setting (default 2), and a list under a key is indented under it, even if your file used the other style. --yaml-compact-seq-indent keeps list dashes in the parent's column.
4. Quotes are kept where they matter and where they were: "80:80" and "yes" stayed quoted. Values that look like another type must stay strings:
$ yq '.ver, .num' blk.yaml (ver: "1.10" is a string, num: 1.10 is a float)
1.10
1.10
$ yq '.ver | tag' blk.yaml
!!str
$ yq '.num | tag' blk.yaml
!!float
When you set a value, the type comes from how you write it. Quote what must stay a string (versions, ports in a Compose file, yes and no):
$ yq '.ver = "1.11"' blk.yaml
$ yq '.ver = 1.11' blk.yaml (now a float: a version like 1.10 would become 1.1)
You can set the style of a node explicitly:
$ yq '(.. | select(tag == "!!str")) style="double"' base.yaml
app: {name: "shop", port: 80, tags: ["a", "b"]}
db: {pool: 5}
$ yq '.app.name style="single" | .app' config.yaml | head -2
name: 'shop' # the service name
version: 2.4.1
$ yq -P base.yaml (idiomatic block style, flow maps expanded)
app:
name: shop
port: 80
tags:
- a
- b
db:
pool: 5
A rule that avoids surprises: run yq . file (or yq -P file) once on a new file and diff it with the original, before you automate edits. That shows exactly what the formatter will change on the first run, and then only your edit shows up afterwards.
Several documents and several files
A YAML file can hold many documents separated by ---, and kubectl, Helm and CI files use this a lot. By default yq runs the expression on each document in turn, and prints a separator between the results:
$ yq .kind multi.yaml
ConfigMap
---
Deployment
---
Service
$ yq -N .kind multi.yaml (no separators)
ConfigMap
Deployment
Service
$ yq -N .kind multi.yaml | sort | uniq -c
1 ConfigMap
1 Deployment
1 Service
select picks documents, and the same assignment edits only the matching one, in place:
$ yq 'select(.kind == "Deployment") | .spec.template.spec.containers[].image' multi.yaml
shop:2.4.1
envoy:1.31
$ yq 'select(document_index == 1) | .metadata.name' multi.yaml
shop
$ yq -i '(select(.kind == "Deployment") | .spec.replicas) = 5' multi.yaml
$ yq '.spec.replicas' multi.yaml
null
---
5
---
null
A nested path inside a document works the same way. This changed the image of one named container and left the others:
$ yq '(select(.kind == "Deployment") | .spec.template.spec.containers[] | select(.name == "app") | .image) |= sub(":.*", ":2.6.0")' multi.yaml
...
containers:
- name: app
image: shop:2.6.0
- name: sidecar
image: envoy:1.31
eval-all (ea) loads every document of every file first, so an expression can see them all at once. Use it to count, to compare, and to merge files. fi is the file index (0 for the first file) and di the document index:
$ yq ea '[.] | length' multi.yaml
3
$ yq ea 'select(.kind == "Deployment") | .spec.replicas' multi.yaml
5
Merging uses the * operator (a deep merge; the right side wins) on documents selected by file index. base.yaml and over.yaml:
$ cat base.yaml
app: {name: shop, port: 80, tags: [a, b]}
db: {pool: 5}
$ cat over.yaml
app: {port: 8080, tags: [c]}
db: {host: db2}
$ yq ea 'select(fi == 0) * select(fi == 1)' base.yaml over.yaml
app: {name: shop, port: 8080, tags: [c]}
db: {pool: 5, host: db2}
Lists are replaced by default. The operator has suffixes that change that:
*+ append arrays tags: [a, b, c]
*d merge arrays by index tags: [c, b]
*n only add keys that are missing keeps port: 80 and tags: [a, b]
$ yq ea 'select(fi==0) *+ select(fi==1)' base.yaml over.yaml | head -1
app: {name: shop, port: 8080, tags: [a, b, c]}
$ yq ea 'select(fi==0) *n select(fi==1)' base.yaml over.yaml | head -1
app: {name: shop, port: 80, tags: [a, b]}
To merge any number of files, fold them with ireduce. Later files win:
$ yq ea '. as $i ireduce ({}; . * $i)' base.yaml over.yaml
app: {name: shop, port: 8080, tags: [c]}
db: {pool: 5, host: db2}
For one extra file in a normal run, load("over.yaml") reads it as data, so yq 'load("over.yaml") * .' base.yaml merges with the file on the left.
-s writes each document to its own file, named by an expression. It created three files here, named from each document:
$ yq -s '.metadata.name + "-" + .kind' multi.yaml
$ ls *.yml
shop-Deployment.yml shop-Service.yml shop-config-ConfigMap.yml
Anchors, aliases and merge keys
YAML lets you define a block once (an anchor, &name) and reuse it (an alias, *name), and the key <<: merges a mapping in. Docker Compose files use it to share settings. yq keeps these untouched when it can:
$ cat anc.yaml
defaults: &d
restart: always
user: app
web:
<<: *d
image: nginx
worker:
<<: *d
image: shop
user: batch
$ yq .web anc.yaml
<<: *d
image: nginx
$ yq '.defaults | anchor' anc.yaml
d
To see what a service really contains, expand the aliases with explode(.). It is how you check merged settings before a deploy:
$ yq '.web | explode(.)' anc.yaml
restart: always
user: app
image: nginx
The lab found a trap in how explode orders the keys. YAML says that a key written in the mapping itself wins over a merge key, wherever <<: appears. In yq v4.54.1 the default is the old behaviour: when the explicit key comes before <<:, the merged value overwrote it, and yq printed a warning on standard error:
$ cat anc2.yaml
defaults: &d
user: app
restart: always
w1:
user: batch
<<: *d
w2:
<<: *d
user: batch
$ yq 'explode(.) | [.w1.user, .w2.user]' -o json -I0 anc2.yaml
time=... level=WARN msg="--yaml-fix-merge-anchor-to-spec is false; causing merge anchors to override the existing values which isn't to the yaml spec. ..."
["app","batch"]
$ yq --yaml-fix-merge-anchor-to-spec=true 'explode(.) | [.w1.user, .w2.user]' -o json -I0 anc2.yaml
["batch","batch"]
w1 should be "batch" in both, and with the flag it is. The release notes say the flag will become the default; with v4.54.1 it still was not, so pass it in scripts that read merged anchors. Updates by path that do not call explode are not affected.
Converting between YAML, JSON, XML, TOML and more
Choose the input with -p and the output with -o. Both take a name or its first letters. Reading and writing the same data in several formats, from config.yaml:
$ yq -o json -I0 .app config.yaml
{"name":"shop","version":"2.4.1","debug":false,"port":8080}
$ yq -o props '.db' config.yaml
host = db1.internal
port = 5432
pool = 10
$ yq -o csv .users config.yaml
name,role,active
ann,admin,true
ed,dev,true
kim,dev,false
$ yq -o xml .app config.yaml
<name>shop<!-- the service name --></name>
<version>2.4.1</version>
<debug>false</debug>
<port>8080</port>
$ yq -o toml '{"app": .app, "db": .db}' config.yaml
[app]
name = "shop" # the service name
version = "2.4.1"
debug = false
port = 8080
[db]
host = "db1.internal"
port = 5432
pool = 10
$ yq -o shell .app config.yaml (variables you can eval; see the scripts section)
name=shop
version='2.4.1'
debug=false
port=8080
Reading other formats into YAML or JSON:
$ echo '{"a": [1, 2], "b": {"c": null}}' | yq -p json
a:
- 1
- 2
b:
c: null
$ printf 'x,y\n1,two\n3,four\n' | yq -p csv -o json -I0
[{"x":1,"y":"two"},{"x":3,"y":"four"}]
$ printf '[srv]\nport = 8080\nname = "x"\n' | yq -p toml -o json -I0
{"srv":{"port":8080,"name":"x"}}
$ printf '[db]\nhost = h1\nport = 5\n' | yq -p ini -o json -I0
{"db":{"host":"h1","port":"5"}}
$ printf '[db]\nhost = h1\nport = 5\n' | yq -p ini '.db.port = 6' -o ini
[db]
host = h1
port = 6
Details that matter:
1. Properties and INI values are strings. a=1 read from properties came out as a: "1" (quoted), and the INI port came out as "5". CSV read numbers as numbers (the x column became 1).
2. XML attributes get a prefix, +@, and text next to attributes goes under +content. Repeated elements become a list, a lone one stays a single value; and every XML value is text unless you convert it:
$ echo '<srv env="prod"><host>web1</host><host>web2</host><port>80</port></srv>' | yq -p xml -o json -I0
{"srv":{"+@env":"prod","host":["web1","web2"],"port":"80"}}
$ echo '<srv><host>web1</host></srv>' | yq -p xml -o xml '.srv.host = "x"'
<srv>
<host>x</host>
</srv>
3. Comments go with the data when the target format has them: the XML and TOML output above carried "the service name". JSON has no comments, so they vanish.
4. Not every shape fits every format. CSV and TSV need a list of flat maps, and props flatten paths with dots.
Encoders for single values are in the filter language: @base64, @base64d, @uri, @sh, @json, @yaml, and to_json, from_json, to_yaml:
$ yq '.app.name | @base64' config.yaml
c2hvcA==
$ yq '.app.name | @base64 | @base64d' config.yaml
shop
$ yq '.app.name | @sh' config.yaml
shop
$ yq '.app | to_json(0)' config.yaml
{"name":"shop","version":"2.4.1","debug":false,"port":8080}
$ yq -n '"{\"a\":1}" | from_json | .a'
1
Other formats exist in the option list (kyaml, hcl, lua). kyaml printed a map in a braces-and-commas style with the comment kept; hcl and lua were not tested.
Real files: Compose, Kubernetes, netplan, Ansible
Docker Compose (compose.yaml: services web, app and db). Bump an image tag, add an environment variable and a port, list the images, and remove a volume mount. All of these ran:
$ yq '.services.app.image = "shop:2.5.0"' -i compose.yaml
$ yq '.services[].image' compose.yaml
nginx:1.27
shop:2.5.0
postgres:16
$ yq '.services | to_entries | .[] | .key + " " + .value.image' compose.yaml
web nginx:1.27
app shop:2.5.0
db postgres:16
$ yq '[.services[].image | sub(":.*", "")]' -o json -I0 compose.yaml
["nginx","shop","postgres"]
$ yq '.services.app.environment.TZ = "UTC" | .services.app.environment' compose.yaml
DB_HOST: db
LOG_LEVEL: info
TZ: UTC
$ yq '.services.web.ports += ["443:443"] | .services.web.ports' compose.yaml
- "80:80"
- 443:443
$ yq 'del(.services.db.volumes[0]) | .services.db' compose.yaml
image: postgres:16
volumes: []
Note that 443:443 was added without quotes, while the old entry kept its quotes. Quote port pairs, since some YAML 1.1 parsers read a bare pair such as 22:22 as a number in base 60 (general YAML behaviour, not tested here). Set the style after adding the entry:
$ yq '.services.web.ports += ["443:443"] | (.services.web.ports[] | select(. == "443:443")) style="double" | .services.web.ports' compose.yaml
- "80:80"
- "443:443"
Kubernetes manifests (multi.yaml). Select by kind, list container images, and bump only one container's image in a multi-document file:
$ yq 'select(.kind == "Deployment") | .spec.template.spec.containers[] | .name + "=" + .image' multi.yaml
app=shop:2.4.1
sidecar=envoy:1.31
The in-place edit of one document, shown in the section on several documents, is what a deploy script does: the other documents and the ordering are unchanged. kubectl itself was not available in the lab, so the pipe kubectl get ... -o yaml | yq was not run, though standard input is read in the same way as in the examples above.
netplan (a file that is easy to break by hand). Edit a copy, diff, and only then replace the real file:
$ yq '(.network.ethernets.ens3.dhcp4 = false) | (.network.ethernets.ens3.addresses = ["10.0.0.5/24"])' netplan.yaml > np.new
$ diff netplan.yaml np.new
6c6
< dhcp4: true
---
> dhcp4: false
8a9,10
> addresses:
> - 10.0.0.5/24
On a real machine you would then run sudo netplan try (not run in the lab, because the container has no network interface to manage).
An Ansible inventory in YAML. List the hosts in each group, or make an address list:
$ yq '.all.children | to_entries | .[] | .key + ": " + ([.value.hosts | keys | .[]] | join(","))' inv.yaml
web: web1,web2
db: db1
$ yq '[.all.children[].hosts | to_entries | .[] | .key + "=" + .value.ansible_host] | join(" ")' inv.yaml
web1=10.0.1.11 web2=10.0.1.12 db1=10.0.2.21
Strings, regular expressions, numbers and dates
String functions work on any scalar. Case, trimming, splitting, joining and replacing:
$ yq '.app.name | upcase' config.yaml
SHOP
$ yq '.db.host | split(".") | .[0]' config.yaml
db1
$ yq '.db.host | sub("\.internal", ".example.com")' config.yaml
db1.example.com
$ yq '.db.host | .[0:3]' config.yaml (a slice of a string)
db1
$ yq '.db.host | test("^db")' config.yaml
true
$ yq '.app.version | capture("(?P<maj>[0-9]+)\.(?P<min>[0-9]+)")' config.yaml -o json -I0
{"maj":"2","min":"4"}
$ yq '.app.name | . + "-" + (.|length|tostring)' config.yaml
shop-4
Use upcase and downcase: ascii_downcase and ltrimstr, which jq has, are a parse error in yq v4.54.1. trim exists. Regular expressions use Go's syntax (RE2), and named groups are written (?P<name>...). Backslashes need care: inside a shell single quote the string "\." reaches yq as written, which is what you want.
Numbers keep their written form. Arithmetic works on numbers and strings stay strings, so check the tag when something does not add up:
$ yq '.app.port | . + 1' config.yaml
8081
$ yq '.app.port | tostring | tag' config.yaml
!!str
$ yq '.db | with_entries(.value |= . + 1) | .host' config.yaml
db1.internal1 (a string plus 1 appends the text 1)
yq reads an ISO timestamp as its own type and can convert and compare it:
$ echo 't: 2026-10-17T02:00:00Z' | yq '.t | tag'
!!timestamp
$ echo 't: 2026-10-17T02:00:00Z' | yq '.t | to_unix'
1792202400
$ yq -n '1700000000 | from_unix | tz("UTC") | format_datetime("2006-01-02 15:04")'
2023-11-14 22:13
$ echo 't: 2026-10-17T02:00:00Z' | yq '.t | tz("Europe/Berlin")'
2026-10-17T04:00:00+02:00
$ echo 't: 2026-10-17T02:00:00Z' | yq '.t | . > "2026-01-01T00:00:00Z"'
true
The format string is Go's reference time (2006-01-02 15:04:05), not strftime's %Y-%m-%d. And adding a string to a timestamp means adding a duration, so . + "x" fails with "unable to parse duration [x]". A string function such as sub on a value that yq has tagged as a timestamp does not change it; the lab's sub("T.*", "") on a timestamp returned the original. To treat a timestamp as text, quote it in the file, or convert it with to_string first (not run).
Using yq safely in shell scripts
The same rule as with jq: never paste shell data into the expression. A value that closes the quote can add its own operators. The lab used a name of x" | .app.name = "pwn:
$ NAME='x" | .app.name = "pwn'
$ yq ".app.name = \"$NAME\" | .app.name" config.yaml
pwn (the pasted text became part of the filter)
Pass the data as an environment variable and read it with env() or strenv(). The difference matters:
env(VAR) the text is parsed as YAML, so 3 is a number, true is a boolean, and "a: b" becomes a map (or an error)
strenv(VAR) the value is always a string
$ VER=3 yq -n -o json '.a = env(VER) | .b = strenv(VER)'
{
"a": 3,
"b": "3"
}
$ NAME='x" | .app.name = "pwn' yq '.users[] | select(.name == strenv(NAME)) | .role' config.yaml
$ (no output: it is only a name that matches nothing)
$ NAME=ann yq '.users[] | select(.name == strenv(NAME)) | .role' config.yaml
admin
Use strenv for versions, tags and anything that might look like a number (3.0.0 was safe either way, but 3 and 1.10 are not). Use env when you want the type.
Reading values out. A shell variable from yq, and a loop over a list; yq prints one value per line, so use read -r:
$ HOST=$(yq .db.host config.yaml); PORT=$(yq .db.port config.yaml)
$ echo "$HOST:$PORT"
db1.internal:5432
$ while read -r n; do echo "user=$n"; done < <(yq '.users[] | select(.active) | .name' config.yaml)
user=ann
user=ed
Test before you rely on a value. Without -e a missing key prints the word null and exits 0; with -e a null or false result exits 1:
$ yq .nope config.yaml; echo "rc=$?"
null
rc=0
$ yq -e .nope config.yaml; echo "rc=$?"
null
Error: no matches found
rc=1
$ yq -e .app.debug config.yaml; echo "rc=$?" (the value is false)
false
Error: no matches found
rc=1
Check a file's syntax with yq . file >/dev/null, which exits 1 on an error. Use it in a pre-commit hook:
$ yq . config.yaml >/dev/null && echo "syntax ok"
syntax ok
$ printf 'a: [1\n' | yq . >/dev/null 2>&1; echo "rc=$?"
rc=1
Templates. envsubst replaces ${VAR} inside strings of the document. It works on string values; the default syntax ${PORT:-80} was applied, and the result was a string ("80", quoted):
$ printf 'host: ${HOST}\nport: ${PORT:-80}\n' > tpl.yaml
$ HOST=h1 yq 'with_entries(.value |= envsubst)' tpl.yaml
host: h1
port: "80"
Locking it down. yq can read other files (load) and read the environment, and an optional operator can run commands. Each can be turned off, which is wise when the expression comes from somewhere you do not control:
$ yq --security-disable-env-ops -n '.a = env(HOME)'
Error: env operations have been disabled
$ yq --security-disable-file-ops -n '.a = load("over.yaml")'
Error: file operations have been disabled
$ yq -n '.a = system("/bin/echo"; "hi")'
Error: system operations are disabled, use --security-enable-system-operator to enable
$ yq --security-enable-system-operator -n '.a = system("/bin/echo"; "hi")'
a: hi
The system operator is off by default, which is correct; leave it off. The third example above is the shipped behaviour. And finally the file-name trap: when the first argument is the name of an existing file, yq may take it as the file, not the expression. The lab created a file called .app, and yq .app config.yaml read that file instead of the expression. Use --expression '.app' when you generate file names or expressions from variables.
Errors and exit codes
yq errors go to standard error, start with Error:, and the exit status is 1. The common ones, with the exact text from the lab:
$ printf 'a: [1, 2\nb: 3\n' | yq .
Error: bad file '-': go-yaml load error in parser (while parsing a flow sequence) at L1.C4-L2.C2: did not find expected ',' or ']'
$ printf 'a:\n\t- 1\n' | yq .
Error: bad file '-': go-yaml load error in scanner (while scanning for the next token) at L2.C1: found character that cannot start any token
$ yq . nofile.yaml
Error: open nofile.yaml: no such file or directory
$ yq '.app[' config.yaml
Error: bad expression, could not find matching `]`
$ yq '.users.name' config.yaml
Error: cannot index array with 'name' (strconv.ParseInt: parsing "name": invalid syntax)
$ yq '.app.name | ascii_downcase' config.yaml
Error: 1:15: lexer: invalid input text "cii_downcase"
$ yq '.app.timeout //= 30' config.yaml
Error: '//' expects 2 args but there is 1
What they mean:
1. A tab in the indentation, a missing bracket or a bad colon is a YAML syntax error. The message gives the line and column (L2.C1 is line 2, column 1). The tab one is the most common: YAML forbids tabs for indentation.
2. A lexer: invalid input text error means the expression has a function yq does not have, or a typo. The quoted text is where the parser gave up; the function you meant is a little before it.
3. cannot index array with 'name' means you are one level off: use .users[] | .name or .users[0].name.
4. A wrong expression on a good file does not always fail. .app.name.x printed nothing and exited 0, and a missing key printed null. If a script depends on a value being present, use -e.
Debugging a filter. Build it in stages and print the type at each step:
$ yq '.users | tag' config.yaml
!!seq
$ yq '.users[0] | keys' config.yaml
- name
- role
- active
$ yq '.db.port | line' config.yaml
13
The line operator prints where a node came from, and it had a surprise in the lab: line numbers were one too small for a file that starts with a comment (this port is on line 14), because the header comment is removed before the expression runs. Add --header-preprocess=false for true line numbers: .app.name gave line 2 by default and 3 with the flag, which is correct. -v prints debug logging (it showed, for example, how yq checked whether the expression was a file name), and --debug-node-info prints what yq knows about a node (not run).
Speed and big files
yq parses the whole document into memory. For the YAML you meet in configuration files that does not matter. For a big generated file it does. The lab used a list of 100,000 small maps (a 5.4 MB YAML file, one flow-style map per line), on a 32 GB machine; the times are indicative:
$ yq length big.yaml 1.47 s (peak memory 579 MB)
$ yq 'map(select(.id > 99990)) | length' big.yaml 1.96 s
$ yq -o json -I0 . big.yaml > big.json 2.81 s (5.8 MB)
$ jq length big.json 0.29 s
$ yq length big.json 0.93 s
$ yq -i '.[0].ok = false' big.yaml 3.66 s (one small edit rewrites the whole file)
Three practical points. First, a 5 MB file used about 100 times its own size in memory, so do not point yq at a multi-gigabyte file. Second, once the data is JSON, jq is several times faster than yq on the same job, so for heavy queries convert once with yq -o json and use jq. Third, an in-place edit rewrites the entire file, so editing in a loop makes the cost grow with the file size.
For a few small files the start-up cost is what you see, and it is a fraction of a second. Calling yq in a loop of thousands of iterations is slow for that reason: put the loop inside one yq expression (a map or an update with |=) instead of starting yq for each item.
yq and jq, and the Python yq
yq's language is modelled on jq's, but it is a different program. These are the differences that tripped up the lab:
jq yq (Go)
data model JSON values YAML nodes with tags, comments and styles
default output JSON YAML (or what the file extension says)
raw strings needs -r already bare for YAML
== across types strict loose ("8080" == 8080 is true)
missing key null null, but .a.b on a string prints nothing
edit a file write to a temp file -i, and comments survive
add, first, last, range, limit present not in v4.54.1 (add and last gave a lexer error)
ascii_downcase, ltrimstr, @csv present upcase/downcase, trim; @csv works
//= present not available: use |= (. // default)
regex Oniguruma Go RE2, named groups (?P<n>...)
dates strftime / strptime Go layouts: format_datetime("2006-01-02")
When the job is only to read JSON and do heavy work, use jq; when the file is YAML and you want to keep its comments, use yq. They combine in a pipeline: convert with yq, then filter with jq:
$ yq -o json . config.yaml | jq -r '.users[] | select(.active) | .name'
ann
ed
The Python yq (Ubuntu's package) works the other way: it takes jq's language. It was run in the lab only for the basics, and the notable differences were these:
$ /usr/bin/yq -r .app.name config.yaml
shop
$ /usr/bin/yq .app.name config.yaml
"shop" (JSON out, so quotes unless -r)
$ /usr/bin/yq -y .app config.yaml (-y: write YAML)
name: shop
version: 2.4.1
debug: false
port: 8080
$ /usr/bin/yq -y '.users[] | select(.active) | .name' config.yaml
ann
--- ed
...
$ /usr/bin/yq -i -y '.app.port = 9' config.yaml (-i works, but the head comment was lost)
$ /usr/bin/yq .kind multi.yaml
"ConfigMap"
"Deployment"
"Service"
Its comments are dropped, scalar outputs get odd document markers, and it needs -y for YAML output; the Go program needs none of that. The same command line therefore means different things: -o and -I are Go yq flags, -y and -r are Python yq flags. Scripts that must run on both are hard to write; pick one and install it explicitly. Other YAML tools (yamllint for style checking, a schema validator for Kubernetes, sops for secrets) are separate and were not run in the lab.
Cheat sheet
Read a key / nested yq .a.b file yq '.a.b[0]' file (YAML out: strings are bare)
Every element yq '.list[]' file yq '.list[].name' file yq '.map.*' file
Filter yq '.list[] | select(.k == "v")' file yq '.list | map(select(.n > 1))'
Test text test("re") contains("a") has("k") key parent path tag length keys
Default .a // "none" (for null and false)
Build yq -n '.a = 1 | .b.c = [1,2]' yq -n '{"a": 1}' -o json -I0
Set / update .a = 1 .a |= . + 1 .a += 1 .list += ["x"] .a *= {"b": 1}
Delete del(.a) del(.list[0]) del(.list[] | select(.k == "v"))
Edit a file yq -i '.a = "x"' file (one file at a time: -i on two files corrupts the first)
Several documents yq 'select(.kind == "X") | ...' f yq -N ... di (document_index) yq -s '.name' f
All files together yq ea '[.] | length' a b fi (file index) yq ea 'select(fi==0) * select(fi==1)' a b
Merge * deep merge *+ append arrays *d by index *n only missing keys . as $i ireduce ({}; . * $i)
Anchors explode(.) anchor alias --yaml-fix-merge-anchor-to-spec=true
Comments line_comment head_comment foot_comment ... comments=""
Styles style="double" style="single" style="" yq -P file
Convert yq -o json|props|csv|tsv|xml|toml|shell|base64|ini yq -p json|xml|csv|toml|ini|props
Env and templates .a = strenv(VAR) (a string) .a = env(VAR) (parsed) envsubst load("f")
Text upcase downcase trim split("x") join(",") sub("a";"b") test("re") capture("(?P<n>.)") @base64 @base64d @uri @sh
Dates tag to_unix from_unix now tz("UTC") format_datetime("2006-01-02") (Go layouts)
Loops and sums map(f) .[] as $i ireduce (0; . + $i) to_entries with_entries(f) group_by unique_by sort_by
Scripts yq -e '.k' f (exit 1 on null or false) yq . f >/dev/null (syntax check)
Output control -o -p -I -P -N -n -e -C -M -0 --expression EXP --from-file prog.yq
Safer runs --security-disable-env-ops --security-disable-file-ops (the system operator is off by default)
Exit codes 0 ok 1 any error (and with -e a null, false or no match)
Common problems
Error: lexer: invalid input text "..." a function that yq does not have (add, last, first, range, ascii_downcase, ltrimstr) or a typo: use the yq form
Error: bad file '-': go-yaml load error ... a YAML syntax error: look at the line and column in the message; the usual causes are a tab or a missing bracket
Error: cannot index array with 'name' you are on a list: use [] or an index, then the key
Error: '//' expects 2 args but there is 1 //= does not exist: .a |= (. // default)
Error: !!int not yet supported for comparison you compared a number with a string using > or <: compare like with like
The output is JSON when I wanted YAML the file is .json: add -o yaml, or use -oy
The output is YAML flow style {a: 1} stdin JSON: add -P for blocks, or -o json for JSON
WARN default output is now 'auto' you used -p json with no -o: give both -p and -o
Wrong yq: --version says yq 0.0.0 you have the Python yq from apt: install the Go binary, check with yq --version
Comment spacing changes in the diff yq writes one space before a trailing comment: run yq . once and commit that, then edit
Folded text > gets rewritten same value on one line: harmless, but run yq . once first to avoid a noisy diff
-i changed the first file and corrupted it -i takes the first file only and appends the others: use a loop or find -exec
The version 1.10 became 1.1 you set it as a number: quote it, .v = "1.10"
A number came out as the wrong type from env env parses the text as YAML: use strenv for strings
My $VAR injection changed another key the value became part of the expression: use strenv(VAR), not "$VAR" in the filter
Prints null but exit 0 a missing key is null: use -e or // to handle it
Prints nothing for .a.b.c .a.b is a string (indexing into a scalar prints nothing): check the type with | tag
Line numbers are one off a header comment is removed first: --header-preprocess=false
A merge key overrode my explicit value (explode) yq v4.54.1 default: pass --yaml-fix-merge-anchor-to-spec=true
yq .app config.yaml printed the wrong thing a file called .app exists: use --expression '.app'
Merged lists lost items * replaces arrays: use *+ to append or *d to merge by index
Slow or big memory on a huge file yq loads it all (about 100 times the file size in the lab): convert to JSON once and use jq
Works with mikefarah yq, fails with Ubuntu's the apt yq is Python and uses jq's language and -y: use one yq and say which in the script
Tips
Check which yq you have (yq --version) at the top of any script, and fail if it is not the Go one: yq --version | grep -q mikefarah.
Take a copy before an in-place edit, and diff afterwards. Run yq . on a new file first and commit that, so later diffs show only your changes.
Edit files one at a time. Never give -i more than one file.
Pass data with strenv(VAR), never by pasting it into the expression. Turn off env, file and system operations when the expression is not yours.
Quote versions and anything that must stay a string when you set it: .image_tag = "1.10". Compose port pairs and yes/no values too.
Use -e in scripts so a missing key is an error, and use // for a deliberate default.
Use parentheses around a path with select when you assign: (.list[] | select(.k == "v") | .x) = 1.
Look before you write: print the node you are about to change (the same expression without the =), then add the assignment.
Use explode(.) to see the real content of a file with anchors, and pass --yaml-fix-merge-anchor-to-spec=true when you do.
Convert with yq -o json and filter with jq when the data is big or the filter is heavy; keep yq for edits and for formats with comments.
Use eval-all with fi and di, or ireduce, to combine files; use a loop to edit many.
When a filter fails to parse, suspect a jq function that yq lacks before you suspect your syntax; the lab found add, first, last, range, limit, ascii_downcase, ltrimstr and //= missing in v4.54.1, with upcase, trim, ireduce, |= (. // x) and index access as the replacements.
Keep the version of yq in your CI image pinned and checked by checksum, since the language and the defaults (the merge-anchor flag, the output format) change between releases.