Writing jq Filters With LLMs in October 2026: Paste Real Sample Input, Handle Nulls on Purpose, and Test Before You Pipe

jq is one of those tools almost everyone uses and almost nobody remembers. You need to pull three fields out of an API response, flatten a nested array into CSV, or count records by status, and you end up asking an LLM for the filter. That works well most of the time. The failures are quiet: a filter that returns nothing instead of an error, a default that swallows a real false, or a shell command that breaks the moment a value contains a space. This guide covers how to ask for jq filters so the first answer is usually right, and how to check the ones that aren't.

Example JSON document with nested keys and values

Example JSON code. Image: Dcbmariano, CC BY 4.0, via Wikimedia Commons.

Why LLM-written jq filters go wrong

jq looks simple, but its behavior around missing data is subtle, and models tend to write for the happy path. The common problems are:

  • Guessed structure. If you describe your JSON in words instead of pasting it, the model invents field names and nesting. The filter then runs without error and prints null over and over.
  • Null handling by accident. In jq, .user.name on an object with no user key returns null quietly, but .items[] on a missing items stops with "Cannot iterate over null". Generated filters often get one case right and not the other.
  • Version and implementation drift. Functions like pick arrived in jq 1.7. Older distro packages, and alternative implementations such as gojq and jaq, differ in some functions and edge cases. A filter that works in the model's head may not work on your machine.
  • Shell quoting. Models sometimes splice shell variables straight into the filter string, which breaks on quotes and spaces and can change what the filter does.

Step 1: Give the model real sample input

The single best thing you can do is paste a small, real sample of the JSON, trimmed down but with the actual shape. Include at least one "awkward" record: a missing field, an empty array, a null, a boolean that is false. If the data is sensitive, replace values but keep keys, types, and nesting exactly.

A good request looks like this:

I'm using jq 1.7.1 (output of jq --version) in bash.
Input is a JSON array like this (2 of ~5,000 records):

[
  {"id": 101, "status": "active", "owner": {"email": "a@example.com"}, "tags": ["beta"], "archived": false},
  {"id": 102, "status": "disabled", "owner": null, "tags": [], "archived": true}
]

I want tab-separated output: id, status, owner email (or "-" if missing),
and the tags joined with commas. Skip archived records.
Explain what happens for record 102.

Naming the version and the shell matters. Asking the model to explain what happens to the awkward record forces it to reason about nulls instead of skipping over them.

Step 2: Know the null and default traps

You'll review generated filters faster if you know where jq tends to surprise people.

The alternative operator treats false like null

a // b returns b when a produces null or false (or nothing). So .archived // true turns every real false into true. For booleans, test for the key explicitly instead:

if has("archived") then .archived else true end

Iterating over something that might be missing

.tags[] errors when tags is missing or null. Either use the optional form .tags[]?, which produces nothing instead of an error, or supply a default first: (.tags // [])[]. Be deliberate about which you want. Suppressing errors with ? everywhere hides real schema changes.

select keeps or drops whole values

select(.status == "active") passes the input through when the condition is true and outputs nothing otherwise. A filter that ends with an empty result often has a select comparing against the wrong type, such as the string "101" instead of the number 101.

Here is a filter for the request above that handles each of these cases on purpose:

jq -r '
  .[]
  | select(.archived != true)
  | [ .id,
      .status,
      (.owner.email // "-"),
      ((.tags // []) | join(","))
    ]
  | @tsv
' records.json

Note that .owner.email is safe even when owner is null, because indexing null with a key returns null. And @tsv and @csv need an array as input and -r to print raw text instead of a quoted JSON string.

Step 3: Pass shell values with --arg, never string splicing

If a filter needs a value from your script, ask for --arg or --argjson explicitly. Reject anything that builds the filter with double quotes and $VAR inside it.

# Fragile: breaks on quotes and spaces, and the value becomes jq code
jq ".[] | select(.status == \"$STATUS\")" records.json

# Safe: the value is passed as data
jq --arg status "$STATUS" '.[] | select(.status == $status)' records.json

# Numbers need --argjson (or tonumber), because --arg always passes a string
jq --argjson min "$MIN_ID" '.[] | select(.id >= $min)' records.json

The string-versus-number mismatch with --arg is a classic silent failure: select(.id == $id) matches nothing because 101 is not equal to "101".

Pipes and valves on an industrial machine, a metaphor for jq pipelines

Pipes and valves on an industrial machine. Image: Shixart1985, CC BY 2.0, via Wikimedia Commons.

Step 4: Test against fixtures before you pipe anything

Treat a jq filter you'll reuse like a small piece of code. Keep it in a file, keep sample inputs next to it, and check the output.

  1. Save the filter as report.jq and run it with jq -r -f report.jq. Files avoid quoting problems entirely.
  2. Save two or three fixture files: a normal record set, one with missing and null fields, and an empty array.
  3. Save the expected output for each, then compare: jq -r -f report.jq fixtures/missing.json | diff - fixtures/missing.expected.tsv.
  4. Run against the empty input and make sure you get empty output, not an error.
  5. Only then run it on the real data, and spot-check a few lines against the source.

When you ask the model to fix a failing filter, paste the exact error and the input that caused it. "It doesn't work" leads to guesses; "Cannot iterate over null (null) on this record" leads to a precise fix.

Step 5: Make scripts fail loudly

By default, jq exits successfully even when the result is null. In scripts, that means a bad field name can quietly produce empty config or empty reports. The -e flag changes this: jq exits with status 1 if the last output was false or null, and 4 if it produced no valid result.

set -euo pipefail
token=$(jq -er '.auth.token' response.json) || {
  echo "no token in response" >&2
  exit 1
}

Ask the model to use -e for any lookup your script depends on, and to explain which exit codes it relies on.

Useful patterns to ask for by name

Models do better when you name the jq building blocks you expect. These cover most day-to-day jobs:

  • Reshape objects: {id, email: .owner.email} builds a smaller object; pick(.id, .owner.email) keeps the original nesting (jq 1.7 or later).
  • Count and group: group_by(.status) | map({status: .[0].status, count: length}).
  • Combine many documents: jq -s (slurp) reads a stream of JSON values into one array, which helps with newline-delimited logs.
  • Work with keys: to_entries, from_entries, and with_entries let you rename or filter keys in a map.
  • Compact output for line tools: jq -c '.[]' prints one JSON object per line, ready for grep, wc -l, or another jq.
  • Find where a field lives: [paths | map(tostring) | join(".")] lists paths in an unfamiliar document so you can paste the right ones back into your prompt.

For very large files, mention the size. Slurping a multi-gigabyte array into memory is a different problem from filtering a 50 KB API response, and jq's --stream mode exists for the first case, though its filters are harder to read and worth testing carefully.

A short checklist

  • Paste a real, trimmed sample with at least one awkward record.
  • State your jq version (jq --version) and shell.
  • Check every // default: could the real value be false?
  • Check every []: what happens if the array is missing or null?
  • Pass values with --arg or --argjson, not string splicing.
  • Keep the filter in a file and diff it against fixture output.
  • Use -e in scripts so a missing value stops the run.

jq rewards the same habit as every other tool an LLM writes for you: show it real input, ask it to explain the edge cases, and run the result on data you already understand before trusting it with data you don't.

Comments

Popular posts from this blog

Grok Bot - a step closer to AGI

Structured Outputs and Constrained Decoding for Reliable LLM APIs in Production (September 2026)

Tencent Hy4 Preview: Open 770B MoE Built for Real Work