午前 2 時。Webhook ハンドラがイベントを無言で落としているとページャーが鳴る。直近 1 時間のペイロードを確認しようと上流エンドポイントを curl したら、6 MB の JSON ダンプがターミナルを埋め尽くす。欲しいフィルタは頭の中でなんとなく見えている — 「status == "retry" かつ attempt > 3 のイベントを全部、テナントごとにグループ化して件数を出す」 — が、正しい jq の呼び出しまで括弧 1 つとパイプ 1 つぶん届かない。フィルタを編集して、ダンプを再パイプして、スクロールして、また試す、なんてやっている余裕はない。本当に欲しいのはスクラッチパッドだ。JSON は一度貼るだけ、フィルタを 10 回いじって、キー入力ごとに出力が即座に再描画される、そういう環境。
それがこのツールの全部だ。公式 jq 1.7 エンジンを WebAssembly にコンパイルし、左に JSON ペイン、右にフィルタペイン、コピーできるプリセット例が 10 個、そしてキー入力ごとに更新されるシンタックスハイライト付きの結果ビュー。ページから外に何も出ない。
なぜ jq なのか
jq は JSON をスライス、変換、集計するための小さな関数型言語である。10 年以上にわたってシェル上で JSON を処理する用途のデファクト標準であり続けたのは、4 つの問題を一度に解いているからだ:連結された JSON ドキュメントを扱えるストリーム指向の評価モデル、Unix パイプのように合成できるパイプ、データそのもののように読めるパス式、そして 1 行から 200 行のプログラムまでスケールする小さな高階コンビネータ群(map、select、reduce、foreach、group_by)。
Playground は jq-web を動かす。jq 1.7 の上流ソースツリーを Emscripten でビルドしたものだ。gzip 後でおよそ 1 MB — 初回訪問時に 1 回だけ取得され、ブラウザにキャッシュされ、そのセッション中はメモリに常駐する。最初のロード以降、フィルタ評価はすべて WASM モジュールへの同期呼び出しになる:ネットワークなし、サーバーなし、アップロードなし。
ひとつ明言しておく。挙動は CLI と同一である。reduce、foreach、def、try/catch、.. の再帰下降、@base64d、splits、paths、walk — 手元の jq で動くものは、ここでも動く。
jq と JSONPath と JMESPath
「JSON ドキュメントを問い合わせる」用途でよく見るのはこの 3 つだ。単純なフィールドアクセスでは重なるが、変換が必要になった瞬間に分かれる。
| 機能 | jq | JSONPath | JMESPath |
|---|---|---|---|
| フィールド・インデックスアクセス | .user.name, .items[0] | $.user.name, $.items[0] | user.name, items[0] |
| 述語でフィルタ | `.items[] | select(.qty > 3)` | $.items[?(@.qty > 3)] |
| 新しいオブジェクトに整形 | map({id, total: .qty * .price}) | 未対応 | items[].{id: id}(射影のみ、算術なし) |
| 集計・畳み込み | reduce .[] as $x (0; . + $x.qty) | 未対応 | 未対応(sum、length、max_by の組み込み関数のみ) |
| 関数定義 | def total: reduce .[] as $x (0; . + $x); | 未対応 | 未対応 |
| 標準実装 | jqlang.org/jq(C、公式) | 互換性のない方言が多数 | jmespath.org(複数 SDK) |
| 出力カーディナリティ | 値のストリーム | マッチした配列 | 単一値 |
要点:JSONPath と JMESPath はクエリ言語で、値を取り出すだけ。jq は変換言語で、値を取り出し、形を変え、集計し、新しい JSON ドキュメントを出力する。タスクが「マッチしたノードを返す」で完結するなら 3 つのどれでもいい。「テナント別エラー数の CSV を返す」で完結させたいなら、1 パスで書けるのは jq だけだ。
形状だけのクエリなら、ZeroTool には JSONPath Tester もある — それだけで足りる場面もある。
実戦で元を取る 5 つのフィルタ
1. ページネーション付き API レスポンスを CSV 用のストリームに平坦化
.results[] | [.id, .tenant, .status, (.created_at | fromdateiso8601)] | @csv
入力 1 行が CSV 1 行になる。ターミナルなら出力を > events.csv でファイルへリダイレクトすればいい。Playground 上ならそのまま結果ペインからコピーする。
2. ログをテナント別にまとめてエラー数を数える
[.events[] | select(.level == "error")]
| group_by(.tenant)
| map({tenant: .[0].tenant, errors: length})
| sort_by(-.errors)
group_by は配列の配列を返す — 内側の各配列は同じキーを共有する。map でグループごとに小さなサマリオブジェクトに射影する。sort_by(-.errors) で降順ソート。「フィールド別のヒストグラム」を書く jq の定型イディオムだ。
3. ネストしたツリーを歩いて特定フィールドをまとめて書き換える
walk(if type == "object" and has("password") then .password = "***" else . end)
walk は後順だ:子から先に訪れて親に遡るので、走査順を気にせず葉ノードを書き換えられる。ペイロードを共有する前に秘密情報をマスクしたり、深さが任意のツリー内で単位を正規化したりするのに使える。
4. foreach で累計を計算する
[foreach .transactions[] as $t (0; . + $t.amount; {date: $t.date, balance: .})]
foreach は reduce の「状態が明示版」のいとこだ。初期状態は 0、更新式は . + $t.amount、取り出し式は {date: $t.date, balance: .}。入力 1 件につき出力 1 件を出すので、最終値ではなく残高の時系列が得られる。
5. 2 つの配列をキーで diff する
def by_id: map({key: .id, value: .}) | from_entries;
(.a | by_id) as $a | (.b | by_id) as $b
| ($a | keys) + ($b | keys) | unique
| map({id: ., a: $a[.], b: $b[.]})
| map(select(.a != .b))
def でヘルパを定義し、両側をキー付きのルックアップに変換し、キー集合を和集合にし、両側を射影して、差分のある行だけ残す。シェルで書くと面倒な処理が、複数行の jq フィルタで一気に書ける。
よくつまずくところ
jq が出力するのはストリームである
.items[] は要素数ぶんの別々の値を生成する。.items は配列 1 つを返す。ターミナルでの表示は似ているが、後段での意味は違う:
.items[] # ストリーム: {...}\n{...}\n{...}
[.items[]] # 配列: [{...}, {...}, {...}]
.items # 配列: 上と同じ、反復なし
フィルタの結果を別の map や length に渡すなら、ほぼ間違いなく配列形にしたい。xargs や別のシェルコマンドにパイプするなら、ストリーム形が欲しい。Playground の結果ペインは両方を見せてくれるので、違いはすぐ目に入る。
ここでは -r(生出力)は効かない
CLI ユーザーは反射的に jq -r で文字列結果の外側のクオートを剥がしにいく。Playground は結果を構造的にレンダリングしていて、テキストとして出しているわけではない — 文字列値は文字列として表示されており、"value"\n の形では出ない。エスケープすべきシェルがないので、-r フラグも存在しない。生のバイト列が必要なとき(たとえばトークンを別フィールドに貼るとき)は、出力からそのままコピーすれば外側のクオートは付いてこない。
null は値であり、「キーがない」もまた別の事実
.user.middle_name は、キーがなくても値が文字どおり null でも null を返す。区別したいなら has("middle_name") か .user | keys を使う。// 演算子(オルタナティブ)は、左辺が null か false のときだけデフォルト値に差し替える:
.user.nickname // .user.name // "anonymous"
これが「最初の非 null フィールド」を表す正しい書き方だ。if .user.nickname then .user.nickname else ... は空文字列や false で期待どおりに動かない。
巨大な入力:あるサイズを超えたら CLI を使う
Playground はモダンなノート PC で 50 MB 程度までの入力なら快適に扱える。それを超えると WASM モジュールのヒープ圧が目立ち始めるので、本物の CLI のストリーミングフラグに切り替えた方がいい:
jq --stream -c 'select(.[0][0] == "events") | .[1]' huge.json
--stream はドキュメント全体をロードせず、[path, value] ペアの列を吐き出す。フィルタは書きづらいが、数 GB のファイルでもスケールする。そのスケールでは Playground は適していない — ローカルに jq をインストールして切り替える。
数値は IEEE 754 倍精度浮動小数
jq は JavaScript と同じく、すべての数値を 64 ビット浮動小数で表現する。2^53 を超える整数 ID は黙って精度を失う。JSON に巨大 ID(Twitter Snowflake ID、一部のデータベース行 ID)が入ってくるなら、ワイヤー上では文字列のまま運んでおくこと — 数値としてパースされてしまえば、jq も Playground も元の正確な値には戻せない。
CLI との住み分け
Playground はフィルタを書くためのスクラッチパッドだ。CLI は本番でそのフィルタを実行するもの。
正しいワークフロー:出力形が満足できるまで Playground でフィルタをプロトタイプし、シングルクォートで囲んでシェルに貼り付ける — jq '<filter>' input.json — として本番実行する。両者はワイヤー互換で、フィルタ構文は何も変わらない。
CLI が唯一の選択肢になる場面:
- 数十 MB を超えるファイル(
--streamを使う)。 - CI パイプラインやシェルスクリプトの中身。
--slurpfileや--argfileで 2 つのファイルを組み合わせる複数入力フィルタ。- jq の
-cコンパクト出力を別プロセスにパイプしたい、長時間動くデータパイプライン。
それ以外 — Webhook ペイロードのデバッグ、アラートクエリの下書き、見慣れない API レスポンスの探索、group_by がどう動くかを同僚に説明する、こうした用途 — では、Playground はターミナルに Alt-Tab で切り替えるより速い。
関連する ZeroTool ツール
- JSONPath Tester — 抽出だけで十分で、変換が要らないとき。
- JSON Formatter — フィルタを書き始める前に、ドキュメントを整形・ミニファイする。
- JSONL Converter — 改行区切り JSON 向け。jq は
--slurpでも、ストリームを反復しても、これをそのまま読める。
さらに読むなら
- jq Language Manual — すべての演算子と組み込み関数の一次リファレンス。
- jq on GitHub — Playground が動かしているエンジンのソース、リリース、イシュートラッカー。
- jq-web — ブラウザ内エンジンを可能にしている Emscripten ビルド。
- JSON RFC 8259 — 上で触れた IEEE-754 の注意点を含む、データモデルの一次ソース。