午前 2 時。Webhook ハンドラがイベントを無言で落としているとページャーが鳴る。直近 1 時間のペイロードを確認しようと上流エンドポイントを curl したら、6 MB の JSON ダンプがターミナルを埋め尽くす。欲しいフィルタは頭の中でなんとなく見えている — status == "retry" かつ attempt > 3 のイベントを全部、テナントごとにグループ化して件数を出す」 — が、正しい jq の呼び出しまで括弧 1 つとパイプ 1 つぶん届かない。フィルタを編集して、ダンプを再パイプして、スクロールして、また試す、なんてやっている余裕はない。本当に欲しいのはスクラッチパッドだ。JSON は一度貼るだけ、フィルタを 10 回いじって、キー入力ごとに出力が即座に再描画される、そういう環境。

jq Playground を開く →

それがこのツールの全部だ。公式 jq 1.7 エンジンを WebAssembly にコンパイルし、左に JSON ペイン、右にフィルタペイン、コピーできるプリセット例が 10 個、そしてキー入力ごとに更新されるシンタックスハイライト付きの結果ビュー。ページから外に何も出ない。

なぜ jq なのか

jq は JSON をスライス、変換、集計するための小さな関数型言語である。10 年以上にわたってシェル上で JSON を処理する用途のデファクト標準であり続けたのは、4 つの問題を一度に解いているからだ:連結された JSON ドキュメントを扱えるストリーム指向の評価モデル、Unix パイプのように合成できるパイプ、データそのもののように読めるパス式、そして 1 行から 200 行のプログラムまでスケールする小さな高階コンビネータ群(mapselectreduceforeachgroup_by)。

Playground は jq-web を動かす。jq 1.7 の上流ソースツリーを Emscripten でビルドしたものだ。gzip 後でおよそ 1 MB — 初回訪問時に 1 回だけ取得され、ブラウザにキャッシュされ、そのセッション中はメモリに常駐する。最初のロード以降、フィルタ評価はすべて WASM モジュールへの同期呼び出しになる:ネットワークなし、サーバーなし、アップロードなし。

ひとつ明言しておく。挙動は CLI と同一である。reduceforeachdeftry/catch.. の再帰下降、@base64dsplitspathswalk — 手元の jq で動くものは、ここでも動く。

jq と JSONPath と JMESPath

「JSON ドキュメントを問い合わせる」用途でよく見るのはこの 3 つだ。単純なフィールドアクセスでは重なるが、変換が必要になった瞬間に分かれる。

機能jqJSONPathJMESPath
フィールド・インデックスアクセス.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)未対応未対応(sumlengthmax_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: .})]

foreachreduce の「状態が明示版」のいとこだ。初期状態は 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            # 配列:        上と同じ、反復なし

フィルタの結果を別の maplength に渡すなら、ほぼ間違いなく配列形にしたい。xargs や別のシェルコマンドにパイプするなら、ストリーム形が欲しい。Playground の結果ペインは両方を見せてくれるので、違いはすぐ目に入る。

ここでは -r(生出力)は効かない

CLI ユーザーは反射的に jq -r で文字列結果の外側のクオートを剥がしにいく。Playground は結果を構造的にレンダリングしていて、テキストとして出しているわけではない — 文字列値は文字列として表示されており、"value"\n の形では出ない。エスケープすべきシェルがないので、-r フラグも存在しない。生のバイト列が必要なとき(たとえばトークンを別フィールドに貼るとき)は、出力からそのままコピーすれば外側のクオートは付いてこない。

null は値であり、「キーがない」もまた別の事実

.user.middle_name は、キーがなくても値が文字どおり null でも null を返す。区別したいなら has("middle_name").user | keys を使う。// 演算子(オルタナティブ)は、左辺が nullfalse のときだけデフォルト値に差し替える:

.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 の注意点を含む、データモデルの一次ソース。