Skip to content

Natural Language to jq: Querying JSON on the Linux Command Line

Get correct jq filters from AI by sending the shape of your JSON, not the data — real idioms, the mistakes models make, and how to verify on sample data first.

●By 10 min read

jq is a small functional language that most people use a few times a month, which is exactly the wrong frequency to remember its syntax. "Give me the namespace and name of every pod with more than five restarts" is easy to say and fiddly to write, so it is a natural thing to hand to a model.

Like awk and sed one-liners, jq filters are cheap to verify: they only read data. The risk is a filter that runs cleanly and returns the wrong answer, such as missing records, null where a value should be, or a count of keys instead of a count of items. The workflow below is built to catch that.

Send the Shape, Not the Data

A model does not need your 4 MB of kubectl output. It needs to know which fields exist and how they nest. jq can describe its own input:

# Every leaf path, with array indexes collapsed to [] and de-duplicated

$ jq -r '[paths(scalars) | map(if type == "number" then "[]" else tostring end) | join(".")] | unique[]' pods.json

apiVersion

items.[].metadata.labels.app

items.[].metadata.name

items.[].metadata.namespace

items.[].spec.containers.[].image

items.[].status.containerStatuses.[].restartCount

items.[].status.phase

kind

# Raw paths, if you just want a quick look

$ jq -c 'path(..)' pods.json | head

# Structure with values redacted: strings become "str", numbers 0, arrays cut to 1 element

$ jq -c 'walk(if type == "array" then .[:1] elif type == "string" then "str" elif type == "number" then 0 else . end)' pods.json

The path list is the best input — it covers fields that appear in any element, not just the first. Real kubectl output produces a few hundred paths; filter with grep to the area you care about before pasting.

walk() hides optional fields — cutting arrays to one element shows only the first item's shape. A pending pod with no containerStatuses, or an EC2 instance with no Tags, disappears from the picture, and those are the cases that break filters.

Redaction is a side benefit — shapes carry no secrets, hostnames or customer data. If you do need to send real records, send two or three with jq '.items[:3]', and prefer a local model.

A Prompt That Returns a Testable Filter

State the jq version, give the shape, describe the output format exactly, and ask the model to supply its own test case:

$ kubectl get pods -A -o json > pods.json

$ jq -r '[paths(scalars) | map(if type == "number" then "[]" else tostring end) | join(".")] | unique[]' pods.json > /tmp/shape.txt

$ jq --version

jq-1.6

$ ollama run llama3.2 "Write a jq filter for jq 1.6.

INPUT SHAPE (every leaf path in kubectl get pods -A -o json; [] = array index):

$(grep -E '^items\.\[\]\.(metadata\.(name|namespace)|status)' /tmp/shape.txt)

TASK: for every pod whose containers have more than 5 restarts in total, print

namespace, name and total restarts as tab-separated text, one pod per line.

Rules:

- Reply with one command: jq -r '<filter>' pods.json

- Any field may be missing or null. Say what the filter does in that case.

- Use only builtins that exist in jq 1.6.

- Then give a small sample input covering the edge cases, and the exact

output you expect from it."

A good answer looks like this, and it is correct, including for pending pods that have no container statuses yet:

$ kubectl get pods -A -o json | jq -r '

.items[]

| [.metadata.namespace, .metadata.name,

([.status.containerStatuses[]?.restartCount] | add // 0)]

| select(.[2] > 5)

| @tsv'

prod web-1 7

Name the version — models write for whatever jq their training data favoured. Saying 1.6 rules out builtins your distribution does not have.

Ask for the edge-case behaviour — "any field may be missing" pushes the model toward ? and //, and makes it explain what happens instead of leaving you to find out.

Its sample input is a test, not proof — a model that writes both the filter and the test can share one misunderstanding. Write at least one edge case yourself.

The Idioms Worth Recognising

You do not need to write jq from memory to review it, but you do need to read it. These cover most real filters:

IdiomWhat it does
.items[]Streams each element as a separate output
map(f)Same as [.[] | f]: keeps the result as one array
select(.status.phase != "Running")Passes the input through only if the condition is true
.a.b[]?Iterates if possible, outputs nothing instead of an error on null
.name // "-"Default when the left side is null, false, or produces nothing
to_entries[]Turns an object into key/value pairs you can format
group_by(.spec.nodeName)Sorts and splits an array into arrays of equal keys
reduce .[] as $x ({}; ...)Folds a stream into one value, such as counts per key
[...] | @tsvFormats an array as one tab-separated line (@csv for CSV)
--arg n v / --argjson n 5Passes a shell value in as $n: a string, or parsed JSON

# Pods not in Running state

$ kubectl get pods -A -o json | jq -r '.items[] | select(.status.phase != "Running")

| [.metadata.namespace, .metadata.name, .status.phase] | @tsv'

# Every distinct image running in the cluster

$ kubectl get pods -A -o json | jq -r '[.items[].spec.containers[].image] | unique[]'

# Pod count per node

$ kubectl get pods -A -o json | jq -c '.items | group_by(.spec.nodeName)

| map({node: (.[0].spec.nodeName // "unscheduled"), pods: length})'

# Pods per phase, with reduce

$ kubectl get pods -A -o json | jq -c 'reduce .items[] as $p ({}; .[$p.status.phase] += 1)'

{"Running":2,"Pending":1}

# Labels as key=value lines

$ jq -r '.metadata.labels | to_entries[] | "\(.key)=\(.value)"' pod.json

# A shell variable as a filter argument, never by splicing it into the quotes

$ jq -r --arg ns "$NS" '.items[] | select(.metadata.namespace == $ns) | .metadata.name' pods.json

sponsored

The Mistakes Models Make

Every one of these runs without an error, or with an error that does not point at the real cause:

MistakeSymptomFix
No -r flagStrings keep their quotes: "web-1" breaks loops and comparisonsjq -r
.[] | select(...) | lengthPrints the key count of each object, not the number of matches[.[] | select(...)] | length
.a.b[] on a missing fieldCannot iterate over null.a.b[]? or (.a.b // [])[]
.enabled // trueA real false is replaced by trueif .enabled == null then true else .enabled end
--arg for a numberselect(.port == $p) silently matches nothing--argjson p 8080
@csv on a stringstring cannot be csv-formatted, only arrayWrap the fields: [.a, .b] | @csv
JMESPath or JSONPath syntaxSyntax error, or nothing at alljq paths start with a dot: .Reservations[]

.[] versus map — .items[] | .name produces separate outputs; map(.name) produces one array. Code that pipes into length, sort or unique needs the array form, and models switch between the two without noticing.

// treats false as missing — the alternative operator fires on null and false alike. For boolean fields, that inverts the meaning of an explicit false.

Silent nulls — a typo such as .metdata.name does not error; it prints null for every record. If the output is a column of nulls, check the path against the shape list first.

Shell quoting — jq filters belong in single quotes so the shell leaves $var and ! alone. Models sometimes wrap the filter in double quotes and nest more double quotes inside it.

Versions and Lookalikes

"jq" in a model's answer can mean several slightly different languages:

jq 1.6 versus 1.7 — Ubuntu 22.04, Debian 12 and RHEL 9 ship 1.6; Ubuntu 24.04 ships 1.7.1. 1.7 added pick(), --raw-output0, and if without else. Filters that use them fail on 1.6 with jq: error: pick/1 is not defined or a syntax error.

Large numbers — jq 1.6 converts every number to a double, so a 20-digit ID prints rounded. 1.7 preserves number literals that pass through unchanged, but arithmetic still rounds. Treat big IDs as strings where you can.

gojq — a Go reimplementation that is mostly compatible but does not keep object key order and does exact integer arithmetic, so its output can differ from jq's on the same filter.

Two different yq tools — the Python yq wraps real jq and accepts jq filters. The Go yq by mikefarah has its own jq-like syntax that differs in places. Run yq --version before trusting a YAML filter, or convert with our JSON/YAML converter and use jq itself.

Built-in query languages — aws --query uses JMESPath and kubectl -o jsonpath uses JSONPath. Both look like jq and are not. A model asked for jq sometimes answers in one of them.

Verify on Sample Data Before Real Data

The check that matters is the same one you would use for any generated code: a small input where you already know the answer, including the awkward records.

$ cat > /tmp/sample.json <<'EOF'

{"items":[

{"metadata":{"namespace":"prod","name":"web-1"},"status":{"containerStatuses":[{"restartCount":7}]}},

{"metadata":{"namespace":"prod","name":"api-1"},"status":{"containerStatuses":[{"restartCount":0},{"restartCount":1}]}},

{"metadata":{"namespace":"batch","name":"job-1"},"status":{"phase":"Pending"}}

]}

EOF

$ printf 'prod\tweb-1\t7\n' > /tmp/expected.txt

$ FILTER='.items[] | [.metadata.namespace, .metadata.name, ([.status.containerStatuses[]?.restartCount] | add // 0)] | select(.[2] > 5) | @tsv'

$ diff <(jq -r "$FILTER" /tmp/sample.json) /tmp/expected.txt && echo PASS

PASS

# In scripts, -e sets the exit status from the last output: false or null exits 1

$ jq -e 'all(.items[]; .metadata.name != null)' /tmp/sample.json >/dev/null || echo "bad input"

The same approach works for the AWS CLI. Ask for --output json explicitly, since a profile can default to table or text, and handle instances that have no tags at all:

$ aws ec2 describe-instances --output json | jq -r '

.Reservations[].Instances[]

| [.InstanceId, .State.Name,

(first(.Tags[]? | select(.Key == "Name") | .Value) // "-")]

| @tsv'

i-0abc running web-01

i-0def stopped -

Put the edge cases in the sample — a missing array, a null field, a false boolean, an empty result. A filter tested only on tidy records has not been tested.

Count both sides — compare jq '.items | length' on the input with wc -l on the output. A filter that drops records quietly shows up as a mismatch.

Keep the filter in a file — jq -r -f restarts.jq pods.json avoids shell quoting problems entirely and lets the filter be reviewed and versioned like any other script.

Same rule as everywhere else — the model drafts and you verify, as in our guide to checking AI command explanations.

Frequently Asked Questions

Should I paste my real JSON into an AI model to get a jq filter?

Usually not. Send the shape instead: jq can list every leaf path in a document, with array indexes collapsed, which tells the model what fields exist without exposing any values. If you need real records for context, send two or three and use a local model for anything sensitive.

Why does my jq output have quotes around every string?

jq prints JSON by default, and a JSON string includes its quotes. Add -r (raw output) to print strings without them. This is the most common thing missing from AI-generated jq commands that feed a shell loop.

What is the difference between .[] and map() in jq?

.[] produces each element as a separate output, while map(f) applies f to each element and keeps the result as one array. Use the array form when the next step is length, sort, unique or any operation on the whole collection.

How do I stop jq failing with Cannot iterate over null?

The field you are iterating is missing on at least one record. Use .field[]? to output nothing for that record, or (.field // [])[] to substitute an empty array. Pending Kubernetes pods without containerStatuses and EC2 instances without Tags are common causes.

Will a jq filter from AI work with yq or gojq?

Not reliably. The Python yq passes filters to real jq, but the Go yq by mikefarah has its own syntax, and gojq differs on key ordering and number handling. Tell the model which tool and version you are running, and test the filter on a sample with known output.

sponsored

Related Tools

See also