jq Basics

Working with JSON

Chapter 4 ยท The jq Command Line

Chapters 2 and 3 handled JSON inside programs. Often you only want to look at some JSON: pick one field from an API response, count the items in a file, list the names that match a condition. Writing a script for each question is slow. jq is a small command-line program that reads JSON, applies a short filter you write, and prints the result. This chapter installs it, teaches the filters you will use every day (selecting, filtering, the main options), connects it to curl, and covers the part that trips people up on Windows: quoting a filter correctly in bash, PowerShell and cmd. The next chapter covers reshaping data.

Every example here was run, except where marked
jq 1.8.2 (the latest release when this was written, published 2026-06-20 on jq's GitHub releases page) on Windows 11, with Git Bash, Windows PowerShell 5.1 and cmd.exe, and curl 8.12.1. The sample data and local test server are made up for this chapter. Not run: Linux, macOS, PowerShell 7, and any install method other than the Windows release download (winget, Homebrew, apt and so on exist, but I did not try them). Results that depend on Windows are labelled. The jq manual at jqlang.org is the reference for everything not shown here.

Installing jq on Windows (Checked)

jq was not installed on this machine, so I installed it into a scratch folder instead of changing the system: download the program from the project's GitHub releases page, verify the download, and put it on the PATH for the session. The release page lists a single file for 64-bit Windows, jq-windows-amd64.exe (1,035,264 bytes), and a text file of checksums (sha256sum.txt).

curl -sL -o jq.exe https://github.com/jqlang/jq/releases/download/jq-1.8.2/jq-windows-amd64.exe curl -sL -o sha256sum.txt https://github.com/jqlang/jq/releases/download/jq-1.8.2/sha256sum.txt certutil -hashfile jq.exe SHA256 jq.exe --version
expected sha256: a6fc67fedaf9128a3309a1e2ebb8b986aeccf70122ee46d2cb4849e423f0c627 (from sha256sum.txt) actual sha256 : a6fc67fedaf9128a3309a1e2ebb8b986aeccf70122ee46d2cb4849e423f0c627 (from certutil) checksum: MATCH control: a copy with one extra byte added does not match (good) jq-1.8.2

The two hashes matched, and a deliberately damaged copy did not, which shows the comparison can fail. Always check the hash of a program you download. To make jq available everywhere you would put the .exe in a folder on your PATH (and rename it jq.exe, as above); I did not do that. From here on, jq means this binary. Run jq --version to see which version your shell is actually using.

The Model: Input In, Filter, Output Out

jq reads JSON, runs your filter on each JSON value it reads, and prints every result it produces as pretty-printed JSON. A filter can produce zero, one or many results for a single input, and that idea explains most of jq. All the examples use this sample file, shop.json:

{ "shop": "Corner Shop", "open": true, "owner": {"first": "Ada", "last": "Lovelace", "phone": null}, "products": [ {"sku": "A1", "name": "Tea", "price": 3.5, "stock": 40, "tags": ["drink", "hot"]}, {"sku": "B2", "name": "Coffee", "price": 4.25, "stock": 0, "tags": ["drink", "hot"]}, {"sku": "C3", "name": "Biscuits", "price": 1.99, "stock": 120, "tags": ["food"]}, {"sku": "D4", "name": "Milk", "price": 0.89, "stock": 15} ] }

The simplest filter is ., "the input itself", which makes jq . file.json a formatter and validator (it prints the file indented, or reports an error). A filter is the first argument and the file name comes after; with no file name, jq reads standard input. The rest of the chapter is about the filters.

Selecting: Keys, Indexes and []

FilterResult on shop.json
.shop"Corner Shop"
.owner.first"Ada"
.owner.phonenull (the key exists, its value is null)
.owner.missingnull (no such key; same result, no error)
."shop""Corner Shop" (quotes are needed for keys with spaces or dashes: ."my-key")
.products[0].name"Tea"
.products[-1].name"Milk" (negative counts from the end)
.products | length4
.products[1:3] | map(.sku)["B2","C3"] (a slice: items 1 and 2, end not included)
.products[].namefour results: "Tea", "Coffee", "Biscuits", "Milk"
.owner | keys["first","last","phone"]

[] is the "one result per item" filter: .products[] turned one array into four separate objects, and .products[].name then took name from each. This is the "zero, one or many" idea at work. Notice that a missing key gives null, not an error, which is convenient and also a trap: a typo in a key name looks like missing data. The fourth product, Milk, has no tags at all, and that shows up next:

jq '.products[].tags[]' shop.json jq '.products[].tags[]?' shop.json
"drink" "hot" "drink" "hot" "food" jq: error (at shop.json:11): Cannot iterate over null (null) (exit status 5; this is the first command) "drink" (second command, with ?) "hot" "drink" "hot" "food"

For Milk, .tags is null and [] cannot step through null, so jq printed the five tags from the first three products, then stopped with an error and exit status 5. The ? suffix means "if this fails, quietly produce nothing", and it gave the same five tags with no error. Use ? deliberately: it also hides real mistakes.

Pipes, Commas and Building Output

| feeds the results of the left side into the right side, one at a time, exactly like a shell pipe. , gives several results from the same input. And | binds loosest: .shop | type, (.open|type) does not mean "the type of .shop, then the type of .open". I ran it:

$ jq '.shop | type, (.open|type)' shop.json jq: error (at shop.json:11): Cannot index string with string ("open") "string"

The comma sits inside the right side of the pipe, so (.open|type) ran on the string "Corner Shop" and failed. The fix is to write what you mean: (.shop | type), (.open | type), or .shop, .open | type. When in doubt, add parentheses.

You can also build new values. An object from parts: .products[0] | {name, cost: .price} gave {"name":"Tea","cost":3.5} ({name} is shorthand for {name: .name}). An array from many results: [.products[] | .name | length] gave [3,6,8,4]. These are the building blocks for Chapter 5.

Filtering and the Everyday Functions

select(condition) passes its input through only if the condition is true; map(f) is [.[] | f]. These ran on shop.json:

FilterResult
.products[] | select(.stock > 0) | .nameTea, Biscuits, Milk
.products[] | select(.price < 2) | {name, price}{"name":"Biscuits","price":1.99} and {"name":"Milk","price":0.89}
.products | map(select(.stock == 0)) | length1
.products | map(.price) | add10.63
.products | map(.price) | min, max0.89 then 4.25
[.products[].price] | add / length2.6575 (the average)
.products | min_by(.price) | .name"Milk"
.products | sort_by(.price) | map(.name)["Milk","Biscuits","Tea","Coffee"]
.products | map(.name) | join(", ")"Tea, Coffee, Biscuits, Milk"
.products[] | select(.tags | index("hot")) | .nameTea, Coffee (index gives a position, or null; null is false)
.products[] | select(.name | test("^C"; "i")) | .sku"B2" (test is a regular-expression match; "i" ignores case)
.owner | has("first"), has("middle")true, false (has tests for a key, or an array index)
"first" | in({"first": 1})true (in is has the other way round)
.products | map(.stock) | any(. == 0), all(. > 0)true, false
.products[0].price * 27
.shop | type; .products | type; .open | type"string"; "array"; "boolean"
.products[] | select(.tags != null and (.tags | contains(["food"]))) | .name"Biscuits" (contains on an array tests that every listed item is present)

Two lessons from this table. First, guard against missing keys before calling a function on them: the tags != null and ... in the last row is needed because contains on null would fail for Milk. Second, select produces nothing (not false) when the condition fails, which is why a filter that matches nothing prints nothing at all.

Comparison Traps: Strings Are Not Numbers

jq has a total ordering across types: null < false < true < numbers < strings < arrays < objects. I confirmed it by sorting [1, "1", null, true, false, [], {}] and got [null,false,true,1,"1",[],{}]. The consequence for command-line use: --arg always creates a string, so this filter compares a number with a string:

jq -c --arg min 2 '[.products[] | select(.price > $min) | .name]' shop.json jq -c --arg min 2 '[.products[] | select(.price > ($min | tonumber)) | .name]' shop.json jq -c --argjson min 2 '[.products[] | select(.price > $min) | .name]' shop.json
[] ["Tea","Coffee"] ["Tea","Coffee"]

The first command printed an empty list with exit status 0 and no warning: every number sorts before every string, so 3.5 > "2" is false (jq -n '3.5 > "2"' printed false). Convert with tonumber, or pass the value with --argjson, which parses its argument as JSON. The test jq -n --arg n 5 --argjson m 5 '[$n, $m, ($n == "5"), ($m == 5)]' gave ["5",5,true,true].

The Options You Will Use

OptionEffectWhat I saw
-rRaw output: print strings without quotes.shop gave Corner Shop instead of "Corner Shop"
-cCompact: one line per result.products[0] became one line
-eSet the exit status from the last resulttrue gave exit 0; null gave exit 1; no output at all gave exit 4
-nNo input: run the filter on nulljq -n '1 + 2' printed 3; jq -n '[range(5)]' -c printed [0,1,2,3,4]
-sSlurp: read all inputs into one arrayThree lines {"a":1} {"a":2} {"a":3} became [{"a":1},{"a":2},{"a":3}]; -s 'map(.a) | add' gave 6
--arg name valuePass a string as $nameSee the comparison trap above
--argjson name jsonPass a parsed JSON value as $name2 stayed a number
--argsRemaining arguments become $ARGS.positionaljq -n --args '$ARGS.positional' a b c -c gave ["a","b","c"]
-S, --tab, --indent nSort keys; indent with tabs; indent with n spaces (up to 7)Listed in jq --help
-aASCII output: escape non-ASCII charactersA string with é and an emoji printed as "caf\u00e9 \ud83d\ude00" with -a, and as the characters themselves without it
-f fileRead the filter from a fileUsed in the quoting section below
-bBinary: do not convert line endings (Windows)See "Line endings on Windows" below

-r is the one you will use most, because it makes jq's output usable by other commands: jq -r '.products[].name' prints one name per line, ready for a loop. -e is for scripts: it lets an if test whether a filter found something. Note the three different exit statuses of -e (a "null or false" result is 1, "no output" is 4), and that without -e a filter that matches nothing exits 0.

What jq Does to Your Numbers and Input

I fed jq the awkward inputs from Chapter 1:

Inputjq -c . printedComment
{"a":12345678901234567890}{"a":12345678901234567890}The original digits are kept when you only pass the value through
[12345678901234567890] with .[0] + 112345678901234567000As soon as you do arithmetic, it is an ordinary 64-bit float
1.0 and 1e21.0 and 1E+2Written back the way jq 1.8.2 preserved them, not as 1 and 100
{"a":NaN}{"a":null}jq accepts it as input and prints null; Chapter 1's parsers either failed or kept NaN
{"a":1,"a":2}{"a":2}Last duplicate wins
a UTF-8 BOM before the document{"a":1}Accepted: unlike Node and Python, which both rejected it
{"a":1} {"a":2} (two values)1 and 2 from .ajq reads every value in the input, one after another; this is how it handles JSON Lines

The point for this course: jq's handling of numbers and of invalid input is its own, a third behaviour beside JavaScript's and Python's. When data must pass through all three, do not assume they agree. Chapter 1's rule stands: test the actual inputs.

curl into jq

The most common use of jq is looking inside an API response. curl downloads, jq reads from the pipe (the curl side, including its options, is covered in API Testing & Tooling 6 and 7). I started a tiny server on 127.0.0.1 that returns the sample file, a 404 with a JSON error body, and a 502 HTML page, and tried each way of calling it:

CommandWhat happened
curl -s URL/shop | jq -r '.products[].name'Tea, Coffee, Biscuits, Milk, one per line
curl URL/hello 2>&1 | jq -c . (no -s, stderr merged)jq: parse error: Invalid numeric literal at line 1, column 4 — curl's progress meter text went into jq
curl -s URL/missing | jq -c . (404, JSON body){"error":"not found"}, exits curl 0, jq 0 — nothing says it failed
curl -sf URL/missing | jq -c .No output; curl exit 22, jq exit 0 (jq got empty input)
curl -s --fail-with-body URL/missing | jq -c .{"error":"not found"} printed and curl exit 22
curl -s URL/html | jq . (502, HTML)jq: parse error: Invalid numeric literal at line 1, column 10, jq exit 5
set -o pipefail; curl -sf URL/missing | jq -c .The pipeline's exit status was 22 (curl's), not jq's
code=$(curl -s -o body.tmp -w '%{http_code}' URL/missing), then test $codeStatus 404; the body was kept in a file to read or ignore

Three lessons. A pipe's exit status is the last command's unless you use pipefail, so jq reporting success says nothing about whether the request worked. A server's JSON error body is still valid JSON, so jq prints it as if it were your data; check the status with -f, --fail-with-body or the %{http_code} variable. And "Invalid numeric literal" almost always means the input was not JSON at all: an HTML page or progress text. Look at the raw body first. This is the same mistake as the Unexpected token < error in Chapter 2.

Reading jq's Error Messages

CauseMessageExit
Input cut off: {"a":1jq: parse error: Unfinished JSON term at EOF at line 2, column 05
Input is not JSON: not jsonjq: parse error: Invalid numeric literal at line 1, column 45
Single quotes in the datajq: parse error: Invalid string literal; expected ", but got ' at line 1, column 55
Trailing comma: [1,2,]jq: parse error: Expected another array element at line 1, column 65
Wrong type: .a.b on {"a":1}jq: error (at <stdin>:1): Cannot index number with string ("b")5
.[0] on an objectjq: error (at <stdin>:1): Cannot index object with number (0)5
keys on a numberjq: error (at <stdin>:1): number (1) has no keys5
Filter ends too soon: .a |jq: error: syntax error, unexpected end of file at <top-level>, line 1, column 4: followed by the filter with a caret, then jq: 1 compile error3
File does not existjq: error: Could not open file nosuchfile.json: No such file or directory2

The messages come in three families, and the first words tell you which. parse error means the data is not valid JSON: look at the input. error (at file:line) means the data was fine but the filter did something impossible to it (indexing a number): check the shape of the data, with jq 'type' or jq keys. compile error means the filter itself is malformed: check quoting and parentheses. The exit statuses I saw were 2 (cannot open the file), 3 (compile error) and 5 (parse or run-time error), and, as above, 1 and 4 from -e.

Quoting a Filter on Windows

A jq filter is full of characters the shell wants for itself: |, ", $, parentheses. The right quoting differs per shell. I ran the same filter, .products[] | select(.name == "Tea") | .sku (expected output "A1"), in each:

ShellHow I wrote itResult
Git Bashsingle quotes around the filter, double quotes insideWorks
PowerShell 5.1'.products[] | select(.name == "Tea") | .sku'Fails: jq: error: Tea/0 is not defined — the inner double quotes were removed before jq saw the filter (exit 3)
PowerShell 5.1inner quotes escaped with a backslash: '... == \"Tea\" ...'Works ("A1")
PowerShell 5.1--arg n Tea '... == $n ...'Works: no inner quotes needed
PowerShell 5.1filter in a file: jq -f f.jq shop.jsonWorks
PowerShell 5.1jq --% ".products[] | select(.name == \"Tea\") | .sku" shop.jsonWorks, but everything after --% on the line is passed to jq, so nothing else can follow it
cmd.exedouble quotes around the filter: jq ".shop" shop.jsonWorks
cmd.exesingle quotes: jq '.shop' shop.jsonFails: unexpected INVALID_CHARACTER — cmd does not treat ' as quoting
cmd.exeinner quotes as \"Tea\" inside double quotesWorks
cmd.exe--arg n Tea, and -f f.jqBoth work

Two habits make quoting nearly painless on any shell. Keep strings out of the filter with --arg and --argjson, so the filter has no double quotes. And put long filters in a file with -f: the shell never sees them. I tested Windows PowerShell 5.1 only; PowerShell 7 may pass arguments to programs differently, so check the backslash row there before trusting it. Piping into jq from PowerShell also worked: @{ a = 1; b = 'x' } | ConvertTo-Json -Compress | jq -c '.' printed {"a":1,"b":"x"}.

Line Endings on Windows

I looked at the bytes jq wrote for jq -r .shop shop.json redirected to a file: they ended in 0d 0a (a carriage return and a line feed, CRLF), and with -b in 0a alone. So on Windows, jq writes Windows line endings to files and pipes unless you add -b. That matters when the output goes to a Linux program or into a repository that expects LF. In Git Bash, command substitution (x=$(jq -r .shop shop.json)) still gave a string of 11 characters, without a stray carriage return, both with and without -b, so loops over $(jq -r ...) were fine in my test; other shells and tools may not strip it.

Mistakes while preparing this chapter
1. I wrote .shop | type, (.open|type) expecting two answers. The pipe binds loosest, so the second type ran on the string and jq reported Cannot index string with string ("open"). How to avoid it: when a pipe and a comma appear together, add parentheses around each side.

2. My first --arg min 2 filter printed nothing and exited 0. It compared numbers with the string "2", and every number sorts before every string. How to avoid it: use --argjson for numbers, or tonumber, and treat an unexpectedly empty result as a type problem before a data problem.

3. My first PowerShell test put --% in the middle of a line. It passed the rest of the line (my ; "exit " + $LASTEXITCODE text) to jq as file names. How to avoid it: --% must be the last thing on its line.

4. Two of my test runs hung or misbehaved when launched from another shell. A PowerShell script started from Git Bash waited forever until I closed its input (< /dev/null), and cmd /c script.cmd from Git Bash started an interactive cmd session instead of running the script, because Git Bash rewrote /c as a path. How to avoid it: run each shell's scripts from that shell, and put a timeout on anything automatic.

Hands-On Exercises

Exercise 1

Install jq into a temporary folder without touching your PATH permanently: download the Windows binary and the checksum file from the official releases page, compare the SHA-256 hash with certutil, and print jq --version. Show a control: a copy with one byte added must not match. Then run ten filters on the sample shop.json (a key, a nested key, a null value, an array length, one array item, every name, only in-stock names with -r, the sum of prices, a slice, and the names of products that have a tags field).

๐Ÿ“„ View solution
Exercise 2

Answer ten questions about shop.json with one filter each (in-stock names, how many are out of stock, total stock, average price, cheapest product, the owner's full name, names joined with commas, the hot drinks, a CSV of sku,stock, the products with no tags), checking each answer against a value you worked out by hand, and print PASS or FAIL for each. Add one deliberately wrong expectation to prove the checker can fail. Then show the exit status of a filter that matches nothing, with and without -e, and of -e on a null.

๐Ÿ“„ View solution
Exercise 3

Start a small local server (200 JSON, 404 with a JSON error body, 502 HTML) and call it with curl piped into jq in eight ways: plain, 2>&1 without -s, the 404 with -s, -sf and --fail-with-body, the HTML page, pipefail, and a status check with %{http_code}. Print each exit status. Then run one filter with an inner double-quoted string in Git Bash, Windows PowerShell and cmd, show which quoting works and which does not, and show that --arg and -f work in all three.

๐Ÿ“„ View solution

Chapter 4 Quick Reference

  • jq reads JSON, runs a filter on each value, prints every result; a filter may give zero, one or many results. Windows install: download jq-windows-amd64.exe, check its SHA-256 against sha256sum.txt (jq 1.8.2, 2026-06-20), run jq --version
  • Select: .key, .a.b, ."odd-key", .[0], .[-1], .[1:3], .[] (one result per item); a missing key is null; ? hides errors (and mistakes)
  • | pipes; , gives several results; | binds loosest, so use parentheses; build with {name, cost: .price} and [ ... ]
  • Everyday: select, map, length, keys, type, has, in, contains, add, min_by, sort_by, join, index, test, any, all
  • Type order: null < false < true < numbers < strings < arrays < objects; --arg is always a string, so use --argjson or tonumber for numbers
  • Options: -r raw strings, -c compact, -e exit status (null/false 1, no output 4), -n no input, -s slurp, --arg, --argjson, --args, -f file, -a, -b
  • Exit statuses seen: 0 ok, 2 cannot open file, 3 compile error, 5 parse or run-time error. Message prefixes: parse error = bad data, error (at ...) = filter vs data shape, compile error = bad filter
  • jq keeps big-number text until arithmetic, prints NaN as null, accepts a BOM and duplicate keys (last wins), and reads several values in a row
  • curl -s URL | jq: a 404 with a JSON body looks like success; use -f/--fail-with-body, pipefail or %{http_code}; "Invalid numeric literal" usually means HTML or progress text; do not use 2>&1 without -s
  • Quoting: Bash single quotes; PowerShell 5.1 strips inner double quotes (escape with \", or use --arg or -f); cmd needs double quotes, not single. On Windows jq writes CRLF unless -b
Coming next
Chapter 5 is reshaping data with jq: building objects and arrays, to_entries and with_entries, group_by and unique_by, reduce and variables, conditionals, try, paths and del, the @csv, @tsv, @sh, @base64 and @uri formats, and writing your own functions. Start with Working with JSON 5.