「スキーマが固まらない」「設定や属性が可変」——そんなとき、専用の NoSQL を増やす前に、PostgreSQL の JSONB を思い出してください。RDB のトランザクション・結合・制約を保ったまま、半構造データを1つの列に格納し、索引まで張れます。
ただし JSONB は「何でも入れていい便利な袋」ではありません。正規化列との使い分け、包含・存在演算子の落とし穴、そして索引設計の判断を誤ると、遅くて壊れやすいテーブルになります。この記事は、JSONB を本番品質で使うための判断基準を、PostgreSQL 公式ドキュメントに忠実に整理します。データベース・RLS クラスタとも地続きの、実務テーマです。
この記事のルール:型の挙動・演算子・索引・関数・PostgreSQL 18 の変更点は、すべて PostgreSQL 18 公式ドキュメント(2026年6月時点) に基づきます。JSON 関数は版による差が大きいため(
JSON_TABLEは PG17、IS JSONは PG16 等)、版を明記します。
1. json と jsonb:原則 jsonb
PostgreSQL には json と jsonb の2つの型があります。違いは公式が明快に述べています。
json型は入力テキストの正確なコピーを保存し、処理関数が実行のたびに再パースする。一方jsonbは分解されたバイナリ形式で保存され、入力時の変換コストでわずかに遅いが、再パースが不要なので処理は大幅に速い。jsonbは索引もサポートする。
そして「どちらを使うべきか」も明言されています。
一般に、ほとんどのアプリケーションは jsonb で保存すべき。オブジェクトキーの順序に関するレガシーな前提など、特殊な要件がある場合を除く。
結論:迷ったら jsonb。 ただし jsonb には正規化の副作用があります(公式)。
- 空白を保持しない(整形は失われる)。
- キーの順序を保持しない。
- 重複キーを保持しない(同じキーが複数あると最後の値だけが残る)。
- 数値は内部で
numeric型になる(範囲外を拒否、E記法は正規化、意味のない末尾の0は保持)。
SELECT '{"b":1, "a":2, "a":3}'::jsonb;
-- → {"a": 3, "b": 1} キー順は変わり、重複 a は最後の 3 が勝つ
2. アクセス演算子:-> と ->> を間違えない
最頻出の操作。-> は JSON型を返し、->> はテキストを返す——この違いが命です。
| 演算子 | 戻り値 | 例 |
|---|---|---|
-> | json/jsonb | data -> 'user'(オブジェクト)、data -> 0(配列要素) |
->> | text | data ->> 'name'(文字列として取り出す・比較する) |
#> | json/jsonb | data #> '{user,address,city}'(パス) |
#>> | text | data #>> '{user,address,city}'(パスでテキスト) |
data['key'] | jsonb | 添字アクセス(PG14+、jsonb のみ。代入も可) |
-- 比較・WHERE には ->>(テキスト)。-> はJSON型なので文字列比較に使えない
SELECT * FROM users WHERE profile ->> 'plan' = 'pro';
-- ネストはパスで一発
SELECT profile #>> '{address,city}' AS city FROM users;
-- 添字アクセス(PG14+)。代入も可能
UPDATE users SET profile['plan'] = '"enterprise"'::jsonb WHERE id = 1;
抽出演算子は、キーや要素が存在しなければ NULL を返す(エラーにならない)。これは安全な一方、「キーが無い」と「値が NULL」の区別が必要なら
?(存在演算子)で確認します。
3. 包含 @> と存在 ?:ここに落とし穴がある
JSONB 検索の主役は包含 @> と存在 ? ですが、両方とも直感に反する挙動があります。
包含 @>:順序は無視、でもネストは平坦化しない
公式の原則:「包含される側が、包含する側に構造とデータ内容として一致しなければならない(一部の非一致要素は捨ててよい)。配列要素の順序は無視され、重複要素は1回として扱う」。
-- オブジェクトの部分一致:tags に "pg" を含み plan が "pro" の行
SELECT * FROM docs WHERE data @> '{"plan":"pro"}';
SELECT * FROM docs WHERE data @> '{"tags":["pg"]}'; -- 配列の順序は無視される
落とし穴:@> はネストを平坦化しません。公式の例:
SELECT '[1, 2, [1, 3]]'::jsonb @> '[1, 3]'::jsonb; -- false!(ネストの中の [1,3] とは別物)
SELECT '[1, 2, [1, 3]]'::jsonb @> '[[1, 3]]'::jsonb; -- true(同じネスト階層で問う)
SELECT '{"foo":{"bar":"baz"}}'::jsonb @> '{"bar":"baz"}'::jsonb; -- false(階層が違う)
つまり問う側も、相手と同じ階層構造で書く必要があります。
存在 ?:トップレベルのキーしか見ない
公式:「? は、文字列が jsonb 値のトップレベルにキーまたは配列要素として現れるかを問う。ネストしたキーは見ない」。
SELECT * FROM docs WHERE data ? 'email'; -- トップレベルに email キーがあるか
SELECT * FROM docs WHERE data ?| array['a','b']; -- a か b のどちらかが存在
SELECT * FROM docs WHERE data ?& array['a','b']; -- a と b の両方が存在
-- data ? 'nested_key' は、ネスト内の nested_key を見つけられない(トップレベルのみ)
ネスト内のキー存在を問いたいなら、
#>でパスを掘ってから?を使うか、後述の SQL/JSON パス(@?)を使います。
4. 更新:jsonb_set / || / - / #-
JSONB の部分更新は、専用関数で行います(UPDATE 自体は行全体の書き換え=MVCC 上は新版を作る点に注意)。
-- 値の差し替え/追加(既定で path が無ければ作る)
UPDATE users SET profile = jsonb_set(profile, '{plan}', '"pro"') WHERE id = 1;
-- マージ(|| は浅いマージ。重複キーは右側が勝つ。再帰はしない)
UPDATE users SET profile = profile || '{"verified": true}' WHERE id = 1;
-- キー削除(- はキー/要素、#- はパス)
UPDATE users SET profile = profile - 'temp_token' WHERE id = 1;
UPDATE users SET profile = profile #- '{address,old_zip}' WHERE id = 1;
構築・集約も豊富です。to_jsonb()(任意の SQL 値を変換)、jsonb_build_object()(キー/値交互)、jsonb_agg()(行を JSON 配列に)、jsonb_object_agg()(ペアを JSON オブジェクトに)。
-- 行を1つの JSON 配列に集約してアプリに返す(N+1 を避ける定番)
SELECT u.id, jsonb_agg(jsonb_build_object('id', o.id, 'amount', o.amount)) AS orders
FROM users u JOIN orders o ON o.user_id = u.id
GROUP BY u.id;
PostgreSQL 18 の追加:
jsonbのnullをスカラ型に**NULLとしてキャスト可能に(従来はエラー)。json{b}_strip_nullsに配列内の null も除去するオプション**追加。長い JSON 文字列処理の SIMD 高速化。
5. SQL/JSON パス:複雑な問い合わせ
単純なキー検索を超える条件(「配列のどれかが5超」など)には SQL/JSON パス言語を使います。演算子は @?(パスが何か返すか=boolean)と @@(パス述語の真偽)。
-- items 配列に price > 1000 の要素が存在するか
SELECT * FROM carts WHERE data @? '$.items[*] ? (@.price > 1000)';
-- 関数版:マッチした値を行として取り出す
SELECT jsonb_path_query(data, '$.items[*] ? (@.price > 1000)') FROM carts;
? (条件) がフィルタ式で、@ は「いま評価中の値」を指します。パスには lax(既定) と strict があり、lax は構造の不一致を吸収(配列と単一要素を同一視するなど)、strict は構造エラーを投げます。
JSON_TABLE(PG17) を使うと、JSONB を行の集合に展開できます(FROM 句で表として使える)。jsonb の配列をリレーショナルに正規化したいときの強力な道具です。
-- jsonb 配列を行に展開(JSON_TABLE は PostgreSQL 17 以降)
SELECT t.* FROM orders,
JSON_TABLE(line_items, '$[*]'
COLUMNS (sku text PATH '$.sku', qty int PATH '$.qty')) AS t;
6. 索引設計:GIN か、式B-tree か(最重要の判断)
JSONB の性能は索引で決まります。判断軸はシンプル——「どんなキー・包含でも柔軟に検索したい」なら GIN、「1つの決まったキーを等値/範囲で引く」なら式B-tree。
GIN:柔軟な検索の主役(2つの演算子クラス)
公式の対応表が判断の核心です。
| GIN 演算子クラス | 対応演算子 | 特徴 |
|---|---|---|
jsonb_ops(既定) | ? `? | ?& @> @? @@` |
jsonb_path_ops | @> @? @@ | 存在演算子は不可だが索引が小さく、検索が速い |
公式:「jsonb_path_ops は同じデータの jsonb_ops 索引より通常ずっと小さく、検索の特異性も高く(頻出キーを含むクエリで特に)、検索性能は既定クラスより良いのが普通」。
-- 万能(存在 ? も使いたい):既定の jsonb_ops
CREATE INDEX idx_docs_data ON docs USING gin (data);
-- 包含 @> 中心で速度・サイズ重視:jsonb_path_ops
CREATE INDEX idx_docs_data_path ON docs USING gin (data jsonb_path_ops);
-- これで WHERE data @> '{"plan":"pro"}' が高速化
jsonb_path_opsの唯一の弱点(公式):{"a": {}}のような値を持たない構造には索引エントリを作らないため、それを探すと全索引スキャンになる。存在演算子(?)が要るかどうかで選びます。
式B-tree:決まったキーを等値/範囲で引くなら最安
「常に data->>'status' で等値検索する」のように問うキーが固定なら、GIN より式B-tree が小さく速い。
-- 単一キーの等値/範囲検索:式B-tree が最適(->> はテキストなので必要なら型キャスト)
CREATE INDEX idx_docs_status ON docs ((data ->> 'status'));
-- 数値・日付として範囲検索するならキャストして索引
CREATE INDEX idx_docs_priority ON docs (((data ->> 'priority')::int));
判断まとめ:
- 検索キーが多様・動的、包含/存在を多用 → GIN(既定 jsonb_ops、@>中心なら jsonb_path_ops)。
- 検索キーが1〜数個に固定、等値/範囲 → 式B-tree(その列を正規化列に昇格する判断もあり)。
7. 設計指針:いつ JSONB を使い、いつ列にするか
JSONB は強力ですが、何でも JSONB は設計の失敗です。公式も「両アプローチの共存を推奨」しつつ、こう釘を刺します。
最大限の柔軟性が欲しい場合でも、JSON ドキュメントはある程度固定された構造を持つことが推奨される。
さらに性能・競合に直結する重要な指摘:
どんな更新も行全体に行レベルロックを取る。更新トランザクション間のロック競合を減らすため、JSON ドキュメントは扱いやすいサイズに制限することを検討せよ。理想的には、各 JSON ドキュメントは業務ルール上それ以上分割できない原子的なデータを表すべき。
実務の指針:
| こうする | こうしない |
|---|---|
| クエリ条件・結合キー・制約に使う属性は正規化列に | 検索する値を JSONB に埋めて毎回掘る |
| 可変・疎・スキーマ未確定の付帯情報を JSONB に | 巨大な1ドキュメントに全部詰めて頻繁に部分更新 |
| 外部APIのレスポンスなど「原子的な塊」を JSONB に | 別々に更新したい複数の値を1ドキュメントに同居 |
NOT NULL・型・CHECK で守れる値は列に昇格 | 整合性が要る値を JSONB 任せにする |
-- 良い例:検索/結合/制約に使う列は正規化、可変の付帯情報だけ JSONB
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku text NOT NULL UNIQUE, -- 検索・結合キーは列
price numeric(12,2) NOT NULL CHECK (price >= 0),
status text NOT NULL DEFAULT 'draft',
attributes jsonb NOT NULL DEFAULT '{}' -- カテゴリ毎に異なる可変属性だけ JSONB
);
CREATE INDEX idx_products_attributes ON products USING gin (attributes jsonb_path_ops);
8. 型安全な境界:JSONB は「信頼できない外部入力」
TypeScript から見ると、JSONB 列は**unknownです。DB に何が入っているか型では保証されません。だから読み出した瞬間に検証**するのが鉄則——CLAUDE 流に言えば「境界で narrowing する」。
import { z } from "zod";
// JSONB に入れる構造を単一の真実源として定義
const ProductAttributes = z.object({
color: z.string().optional(),
weightGram: z.number().int().positive().optional(),
certifications: z.array(z.string()).default([]),
});
type ProductAttributes = z.infer<typeof ProductAttributes>;
// DB から読んだ jsonb は unknown。必ず parse して型を確定させる
export function parseAttributes(raw: unknown): ProductAttributes {
return ProductAttributes.parse(raw); // 不正なら例外。壊れたデータをアプリに流さない
}
// 書き込みもパラメータ化(SQLインジェクション対策)。値は JSON.stringify で渡す
export async function setAttributes(id: number, attrs: ProductAttributes) {
const valid = ProductAttributes.parse(attrs); // 書く前にも検証
await pool.query(
"UPDATE products SET attributes = $1::jsonb WHERE id = $2",
[JSON.stringify(valid), id],
);
}
JSONB は柔軟ゆえに「気づかぬうちにスキーマが崩れる」のが最大のリスク。Zod スキーマを単一の真実源にして読み書き両端で検証すれば、柔軟性を保ったまま壊れにくくできます。さらに DB 側でも
CHECK (jsonb_typeof(attributes) = 'object')のような最低限の制約を併用すると堅牢です。
9. まとめ
- 原則 jsonb。json はテキスト保持で再パース、jsonb は分解バイナリで高速+索引可。jsonb はキー順・空白を保持せず、重複キーは最後が勝つ。
->と->>を間違えない。包含@>は順序無視だがネストを平坦化しない、存在?はトップレベルのみ——この2つの落とし穴を覚える。- 索引の判断:柔軟検索=GIN(既定 jsonb_ops、@>中心なら小さく速い jsonb_path_ops)。固定キーの等値/範囲=式B-tree。
- 設計:検索/結合/制約に使う値は正規化列へ。JSONB は「分割不能な原子データ」に留める(更新は行全体ロック)。
- 型安全:読み出した jsonb は
unknown。Zod で境界検証し、書き込みはパラメータ化。 - PG18:jsonb null のスカラキャスト、
json_strip_nullsの配列対応、長いJSONのSIMD高速化。
JSONB は「RDB の堅さ」と「スキーマレスの柔らかさ」を1つのテーブルで両立させる道具です。次は、超大規模テーブルを物理的に分割する 宣言的パーティショニング へ。