スクリプトを書かずに巨大な JSON ファイルから値を取り出す方法
開くことすらできないほど大きな JSON エクスポートから値をひとつ取り出すために、使い捨てのパーサを書く必要はない。JSON からデータを取り出すのは「クエリ」の問題であり、jq・JSONPath・DuckDB がそれに答える——メモリに収まらないほど大きなファイルまで。
手元に JSON ファイルがあり、それについて知りたいことがある。非アクティブなアカウントはどれか。失敗したレコードの ID は何か。種類ごとにイベントは何件あるのか。ファイルは 80 MB——スクロールしきれず、目で追いきれない——ので、つい最初の反応としてエディタを開き、data = json.load(open("data.json")) と書いてループを回し始めてしまう。けれどこの問いを尋ねるのは一度きりだ。数字ひとつのためにそこまで舞台を整えるのは割に合わない。しかもファイルが十分に大きければ、json.load はメモリを食い続けたあげく、答えを出す前に倒れかねない。
一日を救う考え方の転換はこれだ。JSON から値を取り出すのはクエリであって、プログラムではない。 JSON にはデータベースと同じようにクエリ言語があり、しかもどれも一行のコマンドで走る。この記事では、合わせれば出てくる問いの大半を賄える三つ——jq、JSONPath、DuckDB——を取り上げ、それぞれをいつ使うか、そしてファイルが本当にメモリに収まらないときにどうするかを扱う。
使うファイル
events.json と呼ぶことにする。最上位はオブジェクトで、その中に events 配列が入っている。サンプルにはイベントが三件だけ印刷されているが、この配列に二十万件が入っていると想像してほしい。
{
"generatedAt": "2026-07-17T09:00:00Z",
"events": [
{ "id": "e_1001", "type": "login", "userId": 42, "ok": true, "ms": 128 },
{ "id": "e_1002", "type": "upload", "userId": 42, "ok": false, "ms": 940 },
{ "id": "e_1003", "type": "login", "userId": 7, "ok": true, "ms": 96 }
]
}
これに対して答える問いは、実務で実際に出てくるものだ。上のほうにある値をひとつ読む、すべてのレコードからある項目を抜き出す、条件に合うレコードだけを残す、そして配列全体を数える・平均する。
jq——まずはこれ
jq は JSON を解析し、その上でフィルタを走らせる小さなコマンドラインプログラムだ——単一のバイナリで、brew install jq、apt install jq、または jqlang.org から入手できる。フィルタとは「答えがどんな形か」を記述する式で、いちばん単純なフィルタはパスそのものだ。最上位の値をひとつ読むのは、コードで書くパスとほぼ同じ見た目になる。
jq '.generatedAt' events.json
# "2026-07-17T09:00:00Z"
この引用符は、結果が JSON 文字列だと jq が教えてくれているものだ。素のテキストがほしいとき——別のコマンドへ渡すとき——は -r を付けて生の出力にする。
jq -r '.generatedAt' events.json
# 2026-07-17T09:00:00Z
.events[] は配列を走査し、各要素をパイプの先へ順に流し込む。だから「すべてのイベントの type」は、途中に走査をはさんだパスになる。
jq -r '.events[].type' events.json
# login
# upload
# login
絞り込みは select で、条件が成り立つときだけレコードを通す。失敗したイベントの ID を取り出すとこうだ。
jq -r '.events[] | select(.ok == false) | .id' events.json
# e_1002
左から右へ読む。各イベントを取り、ok が false のものを残し、その id を出す。数えるには、結果を [ ] で包んで配列に集め直し、length を取る。
jq '[.events[] | select(.type == "login")] | length' events.json
# 2
四つの小さな部品——パス、走査のための []、絞り込みの select、数える length——だけで、大きなファイルが投げかける問いの多くにはもう答えられる。ある種別をさっと数えたいだけなら、jq に二つの古典的な Unix ツールを足すのが、上のどの書き方より手軽だ。
jq -r '.events[].type' events.json | sort | uniq -c | sort -rn
# 2 login
# 1 upload
メモリより大きいファイルを読む
これまでのすべての背後に上限が潜んでいる。既定では jq は——json.load と同じく——文書全体を読み込み、木構造をまるごとメモリに組み上げてから評価を始める。80 MB なら問題ない。けれどメモリより大きいファイルではそうはいかず、どんな巧妙なフィルタでも変わらない。フィルタが走る前に、パーサはその値を手に持たなければならないからだ。
ここを越える手立ては二つある。ひとつはファイルの形だ。エクスポートが NDJSON——一行に一つの JSON オブジェクト、外側を包む配列なし——であれば、メモリ使用量はファイル全体ではなく一件あたりのレコードの大きさでほぼ決まる。だから何件積み上がっても、メモリ使用量はおおむね一定のままだ。ツールが一行ずつ読み、処理しては捨てるからだ。
# events.ndjson: 一行に一オブジェクト
jq -r 'select(.ok == false) | .id' events.ndjson
使用量を左右するのは最大の一件のレコードであってファイルサイズではない。だからファイルがどれだけ長くなっても、おおむね一定のままだ。データの出力方法を自分が握っているなら、「一つの巨大な配列」ではなく NDJSON を吐くようにする——それが、非常に有効な変更のひとつだ。以後の逐次処理がすんなり進むようになる。
自分が作ったのではない巨大な配列を相手にするしかないなら、木を組み上げるのではなくディスクから読むしかない。jq 自身の --stream モードがまさにそれで、ファイルを歩きながら [path, value] イベントを出し、fromstream(1 | truncate_stream(...)) という定石が最上位の配列を一要素ずつ組み立て直すので、メモリは有界に保たれる。
# big.json は一つの巨大な最上位配列——全体を読み込まず、要素を流して処理する
jq -rn --stream 'fromstream(1 | truncate_stream(inputs)) | select(.ok == false) | .id' big.json
手を出す前に、二つの限界を挙げておく。この書き方が効くのは最上位が配列のときだけで、しかも値を取り出すためのものであって、組み合わせるためのものではない。低レベルで書き間違えやすくもあるので、仕事が「項目を抜く」を超えたら——とりわけ本格的な集計をするなら——DuckDB(後述)や Python の ijson のようなストリーミングパーサのほうが保守しやすい。これらはファイルが本当に収まらないときにだけ使い、収まるものには素の jq のほうが簡単だ。
JSONPath——どこにでも貼れるパス
jq には独自の構文があり、学ぶ価値もあるが、それは jq が入っている場所にしか存在しない。JSONPath はもっと小さな発想——パス式、ただそれだけ——で、多くの言語、そしてツール・エディタ・API クライアントに実装されている。$.store.book[0].title を書いたことがあれば、それが JSONPath だ。同じ問いにきれいに対応する。
| 問い | jq | JSONPath |
|---|---|---|
| 最上位の値ひとつ | .generatedAt | $.generatedAt |
| 各レコードのある項目 | .events[].type | $.events[*].type |
| 条件に合うレコード | .events[] | select(.ok == false) | $.events[?(@.ok == false)] |
| そのレコードからさらにその項目 | .events[] | select(.ok==false) | .id | $.events[?(@.ok == false)].id |
ここでのトレードオフははっきりしていて、明言する価値がある。JSONPath は選択する——値を指し示し、返してくれる。変換も、組み替えも、集計もしない。「種別ごとに ms を平均する」を表す標準の JSONPath は存在しない。グループ化も平均も、選択ではないからだ。だから目安はこうだ。ある場所の値がほしいときは JSONPath を——とりわけ、その式をコードや設定に持ち込む必要があり、そこに jq 依存を増やしたくないときは。そして選んだものから新しい何かを組み立てる必要が出た瞬間に、jq へ切り替える。
移植性についてひとつ注意しておく。JSONPath は 2024 年に正式な IETF 標準 RFC 9535 を得たが、多くのライブラリはそれより古く、あるいは独自の拡張を加えている——そしてフィルタ構文(?(@.ok == false))こそ、それらが食い違う当のところだ。空白、引用符、関数のサポートが違ってくる。ここで示した書き方は jsonpath-plus のようなライブラリでよく使われるもの(下でリンクする評価ツールが動かしているのもこれだ)。別の場所で式に頼る前に、対象の実装のドキュメントを確認する。
グループ化・平均・結合——SQL over JSON
問いが「それぞれ何件か」「平均はいくつか」「これが別のファイルにも出てくるのはどれか」に変わった時点で、選択を離れて分析をしている。jq でもできる——種別ごとに ms を平均するのは一つの式だ。
jq '.events
| group_by(.type)
| map({ type: .[0].type, count: length, avgMs: (map(.ms) | add / length) })' events.json
動くし、一度きりならそれで十分だ。けれど集計が込み入ってくると——複数のグループキー、別ファイルとの結合、集計結果での並べ替え——SQL こそその仕事のために設計された言語であり、DuckDB はコマンドラインから JSON に直接 SQL を走らせる(これも単一のバイナリで、brew install duckdb、または duckdb.org から入手できる)。ファイルを指し示し、SQL の中で配列を展開する。
duckdb -c "
SELECT e.type, count(*) AS n, round(avg(e.ms)) AS avg_ms
FROM (SELECT unnest(events) AS e FROM read_json_auto('events.json'))
GROUP BY e.type
ORDER BY n DESC"
# ┌────────┬───┬────────┐
# │ type │ n │ avg_ms │
# ├────────┼───┼────────┤
# │ login │ 2 │ 112.0 │
# │ upload │ 1 │ 940.0 │
# └────────┴───┴────────┘
read_json_auto はファイルをサンプリングして列とその型を推論するので、前もって宣言するものは何もない。unnest が events リストをイベント一件につき一行へ展開し、e.type が各構造体の中の項目を取る。ここに jq '.events' > tmp.json という前段がないことに注目してほしい。これは重要だ。先に jq で抜き出すと、それ自体がファイル全体をメモリに読み込み、収まらないほど大きなファイルではかえって台無しになる。だから DuckDB には元のファイルをディスクから直接読ませる。(ファイルがもともとメモリに収まるなら、jq の前段でも問題ない——ただ、本当に巨大な場合の手ではない。)DuckDB は「jq で全部メモリに載せる」パイプラインよりサイズにずっと寛容だが、それでも巨大で単一の最上位オブジェクトは一度解析しなければならない。いちばん扱いやすい巨大な形は、NDJSON か、行ごとに走査できる最上位の配列だ。それ以外は、データベースのテーブルに対して書くもの——WHERE、GROUP BY、二つの JSON ファイルの JOIN、計算列での ORDER BY——はすべて、ここでは JSON ファイルに直接効き、宣言すべきスキーマもない。
落とし穴——値を grep で探す
魅力的に見えて静かに失敗する近道がある。grep '"userId"' events.json だ。小さなサンプルでは動くように見え、本番のファイルで裏切る。grep はテキストの「行」を照合するが、JSON は行で構成されていない——値はキーと別の行にあり得るし、オブジェクトは二十行にまたがることも一行も占めないこともあり、圧縮されたファイルは一行なので grep は全部か何も返さないかのどちらかだ。しかもキーと、たまたま同じ文字を含む文字列値とを区別できない。構造が効いてくる瞬間——「このキーの値をよこせ」はまさに構造の話だ——には、JSON をテキストではなく構造として読むものが要る。jq・JSONPath・DuckDB がまさにそれだ。
式が手ごわいときの詰め方
上のフィルタが短いのは、問いが素直だったからだ。本物の問いは厄介になる——条件三つの select、確信の持てない入れ子のパス、いつもずれる括弧——そして式を書き直すたびに 80 MB のファイルで走らせ直して直すのは、遅くて手探りだ。ここが、ブラウザのツールが本当に活きる唯一の場面だ。代表的なひとかけらのデータを JSONPath & jq 評価ツール に貼れば、両エンジンが入力に合わせてリアルタイムに動き、ライブのマッチ数が、そのフィルタが捕まえたのは三件か三万件かを即座に教えてくれる——正しい式へすばやく近づける。入力はブラウザのメモリに置かれるので、まる一 GB ではなく代表サンプル向けだ。式が固まったら、それをディスク上の jq で完全なファイルに走らせる。
解析できないとき
ここまではファイルが妥当な JSON だと前提してきた。エクスポートはしばしばあと一歩で妥当にならない——ダウンロードの途中切れ、末尾のカンマ、あるいは NDJSON と配列の取り違え。あるツールが一行に一オブジェクトを出すのに、単一の配列を期待するものへ渡してしまう(あるいは逆)。パーサはどんなクエリよりも先に失敗するので、まず構文を確認する。jq . file.json は文書全体を読み、読めなければ最初のエラーの行と列を報告する。たいていはそれで、はみ出した文字や取り違えた形を見つけられる。修正してから、クエリを実行する。
それでもスクリプトを書くべきとき
読み取り専用の問いにはクエリが勝つ。スクリプトが値打ちを持つのは、それが「問い」でなくなって「工程」になったときだ。単一の JOIN では表せない論理で複数ファイルを結ぶ、各レコードを外部 API に照会する、変換して結果を書き戻す、あるいは定期的に走らせて保守もするもの。境目はおおよそこうだ。答えを印字した瞬間に消したくなるコードなら、それはクエリであるべきだった。来週もまた走らせるなら、スクリプトを書く。
どの方法を、いつ
| ほしいもの | 使うもの |
|---|---|
| 読み取り専用の問い全般——絞り込み、項目の抜き出し、組み替え | jq |
| 一つまたは複数の値を指す、持ち運べるパス | JSONPath($.a.b[*].c) |
| 規模のあるグループ化・平均・結合・計数 | DuckDB(SQL over JSON) |
| 種別のヒストグラムをさっと | jq -r '…' | sort | uniq -c | sort -rn |
| 手ごわい式の詰め | ブラウザの JSONPath & jq 評価ツール |
| メモリより大きいファイル | NDJSON + jq。分析なら DuckDB、狙った値の抽出なら jq --stream |
| 解析できない | jq . で行と列を突き止める |
「スクリプトを書く」という反射は、JSON をプログラミングの問題として扱うところから来る。たいていはクエリの問題だ——どのレコードが合うか、このキーの値は何か、それぞれ何件か——そう扱えば、十分の寄り道は一行に縮み、答えが出た瞬間にそれを消すことになる。