結論

  • jq に渡す . や .branch のような式をフィルタと呼ぶ。jq の入力は「JSON 値の並び」で、フィルタは値ごとに動く。連結した JSON は値の数だけ結果を出し、壊れた箇所の手前の値は、終了コード 5 で失敗しても stdout に出る。1 値だけを受けるなら -s で全体を読み、length == 1 と型を確かめる
  • -s は全部を読めてから配列を作るので、1 行でも壊れた JSON Lines (1 行に 1 つの JSON を書く形式) では何も出さない。壊れた行だけを捨てるなら -R と fromjson? を使う
  • 偽になるのは false と null だけで、0・""・[] は真、無い項目は null になる。// は false も右辺に置き換える。フラグの判定は真偽でなく == false や > 0 の比較で書く
  • ツリーを辿る recurse(.children[]) は children の無いノードで止まるので、エラーを捨てる ? を付ける。祖先の条件を子孫に効かせるなら、後段の select (条件が真の値だけを残す関数) でなく recurse(f; cond) に書く
  • シェルとの境目では値の形が変わる。@tsv は \ を二重にし、IFS=$'\t' read は空の列を詰め、--arg (シェルの値を jq の変数に渡すオプション) の値は 1 個 128 KiB までしか渡せない。1 件ごとに jq を起動すると、jq 1 回で全件を出す形より桁で遅い

以下は jq 1.8.2 / GNU bash 5.3.20 / Linux x86_64 で実測した。出力の rc= は echo "rc=$?" で出した終了コードで、stderr のエラー文が stdout より先に並ぶのは、パイプへの stdout がバッファされ、終了時に書かれるためである。

連結した JSON は値ごとに処理され、壊れた箇所の手前の値は出力される

jq は入力を 1 値ずつ読む。連結した 2 つの値はそれぞれ結果を出して終了コード 0、正しい値の後ろが壊れていると先頭の値を出して終了コード 5 になる

jq は 1 つの入力に複数の JSON 値があってよい作りで、先頭から 1 値ずつ読み、読めた値から順にフィルタへ流す。連結した JSON はエラーにならず、値の数だけ結果が出る。

$ printf '{"a":1}{"b":2}' | jq -c .; echo "rc=$?"
{"a":1}
{"b":2}
rc=0
$ printf '{"error":"boom"}{"branch":"x"}' | jq -r .branch; echo "rc=$?"
null
x
rc=0

2 つ目の例のように、1 値のつもりで受けた出力が 2 行になり、終了コードは 0 のままになる。シェルの文字列比較で null を弾こうとしても、値は null と x の 2 行なので一致しない。

$ v=$(printf '{"error":"boom"}{"branch":"x"}' | jq -r .branch); [[ $v == null ]] && echo 'null と判定' || echo "null でないと判定: [$v]"
null でないと判定: [null
x]

正しい値の後ろが壊れていると、jq は先頭の値を処理して出力したあと、壊れた箇所に達して終了コード 5 で失敗する。$(...) で受けると、失敗した上で変数に先頭の値が入る。

$ printf '{"a":1} garbage' > bad.json
$ jq -c . bad.json; echo "rc=$?"
jq: parse error: Invalid numeric literal at EOF at line 1, column 15
{"a":1}
rc=5
$ x=$(jq -c . bad.json); echo "rc=$? x=$x"
jq: parse error: Invalid numeric literal at EOF at line 1, column 15
rc=5 x={"a":1}

1 つの値だけを受けたいときは、-s で全体を読んでから 1 値かどうかを見る。-s は全部を読めてから動くので、壊れた入力では何も出さない。

$ jq -s -c 'if length == 1 then .[0] else error("not a single value") end' bad.json; echo "rc=$?"
jq: parse error: Invalid numeric literal at EOF at line 1, column 15
rc=5
$ printf '{"a":1}{"b":2}' | jq -s -c 'if length == 1 then .[0] else error("not a single value") end'; echo "rc=$?"
jq: error (at <stdin>:0): not a single value
rc=5
$ printf '{"a":1}' | jq -s -c 'if length == 1 then .[0] else error("not a single value") end'; echo "rc=$?"
{"a":1}
rc=0

取り出す値の検査も、シェルの文字列でなく jq の型で行う。-e は最後の出力が false か null のときに終了コード 1 を返す。型で見るので、文字列の中の null は弾かない。

$ printf '{"branch":"null チェック"}' | jq -e -s 'length == 1 and (.[0] | type == "object") and (.[0].branch | type == "string" and length > 0)' >/dev/null; echo "rc=$?"
rc=0
$ printf '{"branch":null}' | jq -e -s 'length == 1 and (.[0] | type == "object") and (.[0].branch | type == "string" and length > 0)' >/dev/null; echo "rc=$?"
rc=1
$ printf '{"error":"boom"}{"branch":"x"}' | jq -e -s 'length == 1 and (.[0] | type == "object") and (.[0].branch | type == "string" and length > 0)' >/dev/null; echo "rc=$?"
rc=1

PITFALL: 終了コードを見ないと、壊れた入力の先頭の値だけが後続の処理に渡る。

JSON Lines の 1 行が壊れると、-s は何も出さない

2 行目が壊れた JSON Lines に対し、そのままの jq は 1 行目だけ、-s は何も出さず、-R と fromjson? は壊れた行だけを捨てて残りを出す

-s は読んだ値を全部まとめて 1 つの配列にする。配列ができるのは全部を読めた後なので、途中に 1 か所でも壊れた箇所があると、フィルタは 1 度も動かない。-R は各行を文字列として読み、fromjson? は変換に失敗した行で何も出さない。この組なら壊れた行だけが落ちる。

$ printf '{"a":1}\n{"a":2\n{"a":3}\n' > mid.jsonl
$ jq -c . mid.jsonl; echo "rc=$?"
jq: parse error: Expected separator between values at line 3, column 1
{"a":1}
rc=5
$ jq -c -s 'length' mid.jsonl; echo "rc=$?"
jq: parse error: Expected separator between values at line 3, column 1
rc=5
$ jq -Rc 'fromjson? | .a' mid.jsonl; echo "rc=$?"
1
3
rc=0

-R なしの jq は、閉じていない {"a":2 の続きとして次の行を読むので、3 行目の {"a":3} も失う。追記の途中で末尾の行が切れているファイルも同じで、-s は 0 件、fromjson? は完全な行だけを返す。

$ printf '{"a":1}\n{"a":2}\n{"a":3' > tail.jsonl
$ jq -c -s 'length' tail.jsonl; echo "rc=$?"
jq: parse error: Unfinished JSON term at EOF at line 3, column 6
rc=5
$ jq -Rc 'fromjson? | .a' tail.jsonl; echo "rc=$?"
1
2
rc=0

PITFALL: 2>/dev/null でエラーを捨てると、「壊れていた」と「該当なし」が同じ空の出力になる。

偽は false と null だけで、無い項目は null になる

false と null だけが偽で、0、空文字、空配列、空オブジェクトは真になる。無い項目は null なので not で反転すると通る

jq の条件式で偽になるのは false と null の 2 つだけで、JavaScript と違い 0・""・[] は真になる。

$ echo '[false,null,0,"",[],{}]' | jq -c '.[] | {v: ., truthy: (if . then true else false end)}'
{"v":false,"truthy":false}
{"v":null,"truthy":false}
{"v":0,"truthy":true}
{"v":"","truthy":true}
{"v":[],"truthy":true}
{"v":{},"truthy":true}

select(条件) は、条件が真の値だけを残し、偽の値を捨てる関数である。無い項目を読むと null になるので、not で反転すると「項目が無い」も残る。空配列は真なので、select(.blocked) は空の blocked も通す。

$ echo '[{"id":1},{"id":2,"waiting":false},{"id":3,"waiting":true}]' > w.json
$ jq -c '.[] | select(.waiting | not)' w.json
{"id":1}
{"id":2,"waiting":false}
$ jq -c '.[] | select(.waiting == false)' w.json
{"id":2,"waiting":false}
$ echo '[{"id":1,"blocked":[]},{"id":2,"blocked":["x"]},{"id":3}]' > b.json
$ jq -c '.[] | select(.blocked)' b.json
{"id":1,"blocked":[]}
{"id":2,"blocked":["x"]}
$ jq -c '.[] | select((.blocked | length) > 0)' b.json
{"id":2,"blocked":["x"]}

数値も同じで、select(.rank) は 0 を通す。値と比べる式なら、無い項目は通らない側に倒れる。

$ echo '[{"id":1},{"id":2,"rank":0},{"id":3,"rank":1}]' > r.json
$ jq -c '.[] | select(.rank)' r.json
{"id":2,"rank":0}
{"id":3,"rank":1}
$ jq -c '.[] | select(.rank > 0)' r.json
{"id":3,"rank":1}

PITFALL: 例えば、あるコマンドの JSON 出力から waiting が false の項目だけを選んで削除するスクリプトを select(.waiting | not) で書くと、コマンドの更新で waiting キーが無くなったときに全項目が削除の対象になる。select(.waiting == false) なら 0 件になる。

// は false も右辺に置き換える

.visible // true は false も null も無い項目も true にするので、== false の比較はどの行でも成立しない。.visible == false なら false の行だけが一致する

A // B は、A が false または null のときに B を返す。既定値を入れる用途で使うと false まで既定値に置き換わるので、その結果を false と比べる式は成立しない。

$ echo '[{"id":1,"visible":false},{"id":2,"visible":null},{"id":3},{"id":4,"visible":true}]' > n.json
$ jq -c '.[] | {id, v: (.visible // true)}' n.json
{"id":1,"v":true}
{"id":2,"v":true}
{"id":3,"v":true}
{"id":4,"v":true}
$ jq -c '.[] | if (.visible // true) == false then empty else .id end' n.json
1
2
3
4
$ jq -c '.[] | if .visible == false then empty else .id end' n.json
2
3
4

null や無い項目を「指定なし」として扱うなら、// を挟まずに .visible == false と比べる。

PITFALL: 既定値の補完と、その値との比較は別の式にする。

recurse はリーフで止まり、祖先の条件は 2 引数で子孫へ効かせる

1 の下に visible false の 2 と 4、2 の下に 3 があるツリーで、recurse だけなら全部、後段の select は 3 が残り、2 引数の recurse なら 2 と 3 が消える

.[] は最上位の配列しか回らないので、.[] | select(...) でツリーを絞ると子の要素が対象から外れ、エラーにもならない。ツリーを辿るには recurse を使う。recurse(f) は今の値を出力し、式 f が返す値 (次に辿る先) のそれぞれに同じことを繰り返す。ただし recurse(.children[]) は、children の無いリーフ (子を持たないノード) で null を回そうとしてエラーで止まる。? は直前の式のエラーを捨てて何も出さない記号なので、付けるとリーフで辿る先が無くなり、そこで終わる。

$ echo '[{"id":1,"children":[{"id":2}]}]' > t.json
$ jq -c '.[] | recurse(.children[])' t.json; echo "rc=$?"
jq: error (at t.json:1): Cannot iterate over null (null)
{"id":1,"children":[{"id":2}]}
{"id":2}
rc=5
$ jq -c '.[] | recurse(.children[]?)' t.json; echo "rc=$?"
{"id":1,"children":[{"id":2}]}
{"id":2}
rc=0
$ jq -c '.[] | select(.id == 2)' t.json; echo "rc=$?"
rc=0
$ jq -c '.[] | recurse(.children[]?) | select(.id == 2)' t.json; echo "rc=$?"
{"id":2}
rc=0

「親が非表示なら子も出さない」は後段の select では書けない。select は各ノード自身の値しか見ないので、visible を持たない子が残る。第 2 引数の cond が真の子だけを辿る recurse(f; cond) なら、偽の子とその子孫が丸ごと消える。

$ echo '{"id":1,"children":[{"id":2,"visible":false,"children":[{"id":3}]},{"id":4}]}' > v.json
$ jq -c '[recurse(.children[]?) | .id]' v.json
[1,2,3,4]
$ jq -c '[recurse(.children[]?) | select(.visible != false) | .id]' v.json
[1,3,4]
$ jq -c '[recurse(.children[]?; .visible != false) | .id]' v.json
[1,4]

cond を当てるのは子に対してだけで、ルート (最上位のノード) は条件が偽でも出力され、その子も辿られる。

$ echo '{"id":1,"visible":false,"children":[{"id":2}]}' | jq -c '[recurse(.children[]?; .visible != false) | .id]'
[1,2]

PITFALL: ルートの条件も効かせるなら、recurse の前に select を置く。

@tsv は \ も二重にするので、JSON を列に詰めると値が変わる

@json で改行は 2 文字の \\n になり、@tsv でさらに \\ が二重になる。取り出すと \\n という文字列に変わるが、base64 に包めば元の改行に戻る

@tsv は 1 行に収めるために、改行とタブを \n と \t の 2 文字にし、\ 自身も \\ にする。先に @json で文字列にした JSON の中には \n の 2 文字があるので、@tsv がその \ を二重にする。取り出した JSON の note は、改行ではなく \ と n の 2 文字になる。

$ jq -rn '["a\nb", "c\td"] | @tsv'
a\nb	c\td
$ jq -rn '{note: "a\nb"} | ["id1", @json] | @tsv' > t.tsv
$ cat t.tsv
id1	{"note":"a\\nb"}
$ cut -f2 t.tsv | jq -r .note
a\nb

TSV の 1 列に JSON を載せて後段へ渡すなら、列の中身を base64 で包む。@base64 の出力に \ は入らないので、@tsv を通っても変わらない。

$ jq -rn '{note: "a\nb"} | ["id1", (@json | @base64)] | @tsv' > b.tsv
$ cat b.tsv
id1	eyJub3RlIjoiYVxuYiJ9
$ cut -f2 b.tsv | base64 -d | jq -r .note
a
b

PITFALL: \n を含まない値では壊れないので、短いテストデータでは気づかない。

@tsv の空の列は IFS=$'\t' read で詰められる

a、空、c の 3 列を IFS タブの read で読むと y に c が入ってずれる。区切りを 0x1f に変えると y は空のまま残る

@tsv は空の列も \t\t として出す。列がずれるのは読む側で、タブは IFS whitespace (IFS に入っている空白・タブ・改行のことで、連続を 1 つの区切りにまとめる) に当たるので、read は連続するタブを 1 つの区切りとして扱い、先頭のタブも捨てる。エラーにはならず、隣の列の値が入る。

$ jq -rn '["a", "", "c"] | @tsv' | od -c | head -1
0000000   a  \t  \t   c  \n
$ jq -rn '["a", "", "c"] | @tsv' | { IFS=$'\t' read -r x y z; echo "x=[$x] y=[$y] z=[$z]"; }
x=[a] y=[c] z=[]
$ jq -rn '["", "b", "c"] | @tsv' | { IFS=$'\t' read -r x y z; echo "x=[$x] y=[$y] z=[$z]"; }
x=[b] y=[c] z=[]

空の列を保つなら、awk で読むか、区切りを IFS whitespace でない文字 (ここでは制御文字の 0x1f) に変える。join は @tsv と違って値をエスケープしないので、値に区切り文字が入らないことが前提になる。

$ jq -rn '["", "b", "c"] | @tsv' | awk -F'\t' '{print "x=[" $1 "] y=[" $2 "] z=[" $3 "]"}'
x=[] y=[b] z=[c]
$ jq -rn '["a", "", "c"] | join("\u001f")' | { IFS=$'\x1f' read -r x y z; echo "x=[$x] y=[$y] z=[$z]"; }
x=[a] y=[] z=[c]
$ jq -rn '["", "b", "c"] | join("\u001f")' | { IFS=$'\x1f' read -r x y z; echo "x=[$x] y=[$y] z=[$z]"; }
x=[] y=[b] z=[c]

PITFALL: 全部の列が埋まっているテストでは気づかない。

任意のテキストを JSON の文字列にするなら -R -s

-R は入力を生のテキストとして読み、-s は全体を 1 つの値にまとめる。組み合わせると、stdin 全体が 1 つの JSON 文字列になり、改行は \n、\ は \\ にエスケープされる。シェルの変数を手で JSON に埋め込むより壊れない。末尾の改行も本文に残るので、rtrimstr("\n") で落とす。

$ printf 'x\ny\n' | jq -Rs .
"x\ny\n"
$ printf '*bold* line1\nback\\slash\n日本語\n' | jq -Rsc --arg color good 'rtrimstr("\n") as $m | [{$color, text: $m}]'
[{"color":"good","text":"*bold* line1\nback\\slash\n日本語"}]

変数をキーと値にする略記は {$color} で、$ の無い {color} は .color の略記になる。入力が文字列のときは .color を読めずに落ちる。

$ printf 'x\n' | jq -Rs --arg color good '{color}'; echo "rc=$?"
jq: error (at <stdin>:1): Cannot index string with string ("color")
rc=5

--arg の値は 1 個 128 KiB までしか渡せない

引数 1 個の上限は 131072 バイト未満で、引数全体の上限 ARG_MAX は別にある。stdin なら 3 MB でも渡せる

--arg 名前 値 は、シェルの値を jq の変数 $名前 に渡すオプションである。値はコマンドライン引数なので、jq を起動する exec の上限に当たる。Linux の上限は 2 つあり、引数 1 個は MAX_ARG_STRLEN (ページサイズ 4096 の環境では 131072 バイト、終端の NUL を含む)、引数と環境変数の合計は getconf ARG_MAX (手元では 2097152) までに制限される。超えると exec が E2BIG で失敗し、シェルは終了コード 126 を返す。jq は起動しないので、エラー文は bash が出している。

$ getconf ARG_MAX
2097152
$ long=$(head -c 131071 /dev/zero | tr '\0' a); jq -n --arg x "$long" '$x | length'; echo "rc=$?"
131071
rc=0
$ long=$(head -c 131072 /dev/zero | tr '\0' a); jq -n --arg x "$long" '$x | length'; echo "rc=$?"
bash: line 1: /usr/bin/jq: Argument list too long
rc=126

先に当たるのはたいてい 1 個の上限のほうで、合計が 2 MB に届かなくても落ちる。grep -n や git diff の出力を --arg に渡す処理は、minify された JS や 1 行の JSON が混ざると、1 個の引数がこの上限を超える。合計の上限は、131071 バイトの引数を 10 個なら通り、20 個 (約 2.6 MB) で落ちる。

$ long=$(head -c 131071 /dev/zero | tr '\0' a); args=(); for i in $(seq 10); do args+=("$long"); done; jq -n '$ARGS.positional | length' --args "${args[@]}"; echo "rc=$?"
10
rc=0
$ long=$(head -c 131071 /dev/zero | tr '\0' a); args=(); for i in $(seq 20); do args+=("$long"); done; jq -n '$ARGS.positional | length' --args "${args[@]}"; echo "rc=$?"
bash: line 1: /usr/bin/jq: Argument list too long
rc=126

長い値は stdin で渡す。printf は bash の組み込みコマンドで exec を通らないので、この上限に当たらない。cut -c1-200 のように行の長さを切っておく方法もある。

$ long=$(head -c 3000000 /dev/zero | tr '\0' a); printf '%s' "$long" | jq -Rs 'length'; echo "rc=$?"
3000000
rc=0

PITFALL: 例えば git diff の出力を --arg で JSON に入れて検査ツールへ送るスクリプトが jq の終了コードを見ずに次へ進むと、差分が上限を超えたときだけ、検査ツールに何も送られないまま成功として終わる。

1 件ごとに jq を起動すると、jq 1 回で全件を出す形より桁で遅い

385 件 1.1 MB の JSON で、1 件ごとに全体を読み直すと 3.17 秒、全体を 1 回読んで 1 件ごとに jq を起動すると 1.58 秒、jq 1 回で全件を出すと 0.010 秒

シェルのループの中で jq を起動すると、1 回ごとにプロセスの起動と入力の解析が走る。385 件・約 1.1 MB の JSON で、形だけを変えて同じ値を出した。

$ wc -c spec.json
1105630 spec.json
$ time (jq -r '.paths | keys[]' spec.json | while read -r p; do jq -r --arg p "$p" '.paths[$p].get.summary | length' spec.json; done > /dev/null)

real	0m3.165s
user	0m2.399s
sys	0m0.770s
$ time (jq -c '.paths | to_entries[]' spec.json | while read -r l; do jq -r '.value.get.summary | length' <<<"$l"; done > /dev/null)

real	0m1.580s
user	0m0.777s
sys	0m0.822s
$ time (jq -r '.paths | to_entries[] | .value.get.summary | length' spec.json > /dev/null)

real	0m0.010s
user	0m0.008s
sys	0m0.002s
形時間 (手元の 1 回の実測)
1 件ごとに jq を起動し、毎回全体を読み直す3.17 秒
全体は 1 回だけ読み、1 件ごとに jq を起動する1.58 秒
jq 1 回で全件を出す0.010 秒

ループの中で jq を呼ばず、jq の 1 回の実行で全件分の行を作ってから、シェル側では read するだけにする。

PITFALL: ループを jq 1 回に書き直すと、出力がずれやすい。例えば JSON から API ごとのファイルを生成するスクリプトなら、書き直す前に生成したディレクトリを残し、書き直した後の結果と diff -r old new (ディレクトリの中を再帰的に比べる) で比べて、差分が無いことを確かめる。

まとめ

トピック起きること対策
連結した JSON値の数だけ結果が出て終了コード 0-s で length == 1 と型を確かめる
壊れた箇所の手前手前の値を出して終了コード 5終了コードを見る。-s で全部読んでから使う
JSON Lines の破損-s は何も出さない-R と fromjson?
真偽偽は false と null だけ== false、> 0、length で比べる
//false も右辺に置き換わる既定値の補完と比較を分ける
recurse(.children[])children の無いリーフで止まるrecurse(.children[]?)
祖先の条件後段の select では子孫が残るrecurse(f; cond)。ルートは別に絞る
@tsv に JSON\ が二重になり値が変わる@base64 で包む
IFS=$'\t' read空の列が詰められて列がずれるawk で読むか、IFS whitespace でない区切りにする
テキストの埋め込み手で埋めると壊れる-R -s、{$name}、rtrimstr("\n")
--arg の長さ1 個 128 KiB を超えると終了コード 126長い値は stdin で渡す
ループ内の jq起動と解析が件数分走るjq 1 回で全件を出す

参考