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.
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).
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:
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 []
| Filter | Result on shop.json |
|---|---|
.shop | "Corner Shop" |
.owner.first | "Ada" |
.owner.phone | null (the key exists, its value is null) |
.owner.missing | null (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 | length | 4 |
.products[1:3] | map(.sku) | ["B2","C3"] (a slice: items 1 and 2, end not included) |
.products[].name | four 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:
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:
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:
| Filter | Result |
|---|---|
.products[] | select(.stock > 0) | .name | Tea, Biscuits, Milk |
.products[] | select(.price < 2) | {name, price} | {"name":"Biscuits","price":1.99} and {"name":"Milk","price":0.89} |
.products | map(select(.stock == 0)) | length | 1 |
.products | map(.price) | add | 10.63 |
.products | map(.price) | min, max | 0.89 then 4.25 |
[.products[].price] | add / length | 2.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")) | .name | Tea, 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 * 2 | 7 |
.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:
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
| Option | Effect | What I saw |
|---|---|---|
-r | Raw output: print strings without quotes | .shop gave Corner Shop instead of "Corner Shop" |
-c | Compact: one line per result | .products[0] became one line |
-e | Set the exit status from the last result | true gave exit 0; null gave exit 1; no output at all gave exit 4 |
-n | No input: run the filter on null | jq -n '1 + 2' printed 3; jq -n '[range(5)]' -c printed [0,1,2,3,4] |
-s | Slurp: read all inputs into one array | Three lines {"a":1} {"a":2} {"a":3} became [{"a":1},{"a":2},{"a":3}]; -s 'map(.a) | add' gave 6 |
--arg name value | Pass a string as $name | See the comparison trap above |
--argjson name json | Pass a parsed JSON value as $name | 2 stayed a number |
--args | Remaining arguments become $ARGS.positional | jq -n --args '$ARGS.positional' a b c -c gave ["a","b","c"] |
-S, --tab, --indent n | Sort keys; indent with tabs; indent with n spaces (up to 7) | Listed in jq --help |
-a | ASCII output: escape non-ASCII characters | A string with é and an emoji printed as "caf\u00e9 \ud83d\ude00" with -a, and as the characters themselves without it |
-f file | Read the filter from a file | Used in the quoting section below |
-b | Binary: 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:
| Input | jq -c . printed | Comment |
|---|---|---|
{"a":12345678901234567890} | {"a":12345678901234567890} | The original digits are kept when you only pass the value through |
[12345678901234567890] with .[0] + 1 | 12345678901234567000 | As soon as you do arithmetic, it is an ordinary 64-bit float |
1.0 and 1e2 | 1.0 and 1E+2 | Written 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 .a | jq 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:
| Command | What 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 $code | Status 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
| Cause | Message | Exit |
|---|---|---|
Input cut off: {"a":1 | jq: parse error: Unfinished JSON term at EOF at line 2, column 0 | 5 |
Input is not JSON: not json | jq: parse error: Invalid numeric literal at line 1, column 4 | 5 |
| Single quotes in the data | jq: parse error: Invalid string literal; expected ", but got ' at line 1, column 5 | 5 |
Trailing comma: [1,2,] | jq: parse error: Expected another array element at line 1, column 6 | 5 |
Wrong type: .a.b on {"a":1} | jq: error (at <stdin>:1): Cannot index number with string ("b") | 5 |
.[0] on an object | jq: error (at <stdin>:1): Cannot index object with number (0) | 5 |
keys on a number | jq: error (at <stdin>:1): number (1) has no keys | 5 |
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 error | 3 |
| File does not exist | jq: error: Could not open file nosuchfile.json: No such file or directory | 2 |
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:
| Shell | How I wrote it | Result |
|---|---|---|
| Git Bash | single quotes around the filter, double quotes inside | Works |
| 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.1 | inner quotes escaped with a backslash: '... == \"Tea\" ...' | Works ("A1") |
| PowerShell 5.1 | --arg n Tea '... == $n ...' | Works: no inner quotes needed |
| PowerShell 5.1 | filter in a file: jq -f f.jq shop.json | Works |
| PowerShell 5.1 | jq --% ".products[] | select(.name == \"Tea\") | .sku" shop.json | Works, but everything after --% on the line is passed to jq, so nothing else can follow it |
| cmd.exe | double quotes around the filter: jq ".shop" shop.json | Works |
| cmd.exe | single quotes: jq '.shop' shop.json | Fails: unexpected INVALID_CHARACTER — cmd does not treat ' as quoting |
| cmd.exe | inner quotes as \"Tea\" inside double quotes | Works |
| cmd.exe | --arg n Tea, and -f f.jq | Both 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.
.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
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).
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.
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.
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 againstsha256sum.txt(jq 1.8.2, 2026-06-20), runjq --version - Select:
.key,.a.b,."odd-key",.[0],.[-1],.[1:3],.[](one result per item); a missing key isnull;?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;
--argis always a string, so use--argjsonortonumberfor numbers - Options:
-rraw strings,-ccompact,-eexit status (null/false 1, no output 4),-nno input,-sslurp,--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,pipefailor%{http_code}; "Invalid numeric literal" usually means HTML or progress text; do not use2>&1without-s- Quoting: Bash single quotes; PowerShell 5.1 strips inner double quotes (escape with
\", or use--argor-f); cmd needs double quotes, not single. On Windows jq writes CRLF unless-b
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.