メインコンテンツへスキップ
友田 陽大
PostgreSQL本体・性能設計
PostgreSQL
アーキテクチャ設計
TypeScript

PostgreSQL JSONB 実践ガイド(json との違い・演算子・GIN/式インデックス設計・型安全な境界・v18対応)

PostgreSQL の JSONB を本番で正しく使うための実践ガイド。json と jsonb の違い、->/->>/@>/? などの演算子、包含・存在の落とし穴、jsonb_set などの更新、SQL/JSON パス、GIN(jsonb_ops と jsonb_path_ops)と式B-treeの索引設計の判断、正規化列との使い分け、TypeScript/Zod での型安全な境界までを公式ドキュメントに忠実な実コードで解説します。

公開日
読了時間
12分
著者
友田 陽大
シェア

「スキーマが固まらない」「設定や属性が可変」——そんなとき、専用の 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 には jsonjsonb の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/jsonbdata -> 'user'(オブジェクト)、data -> 0(配列要素)
->>textdata ->> 'name'(文字列として取り出す・比較する)
#>json/jsonbdata #> '{user,address,city}'(パス)
#>>textdata #>> '{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 の追加jsonbnull をスカラ型に**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 は unknownZod で境界検証し、書き込みはパラメータ化。
  • PG18:jsonb null のスカラキャスト、json_strip_nulls の配列対応、長いJSONのSIMD高速化。

JSONB は「RDB の堅さ」と「スキーマレスの柔らかさ」を1つのテーブルで両立させる道具です。次は、超大規模テーブルを物理的に分割する 宣言的パーティショニング へ。


参考(PostgreSQL 18 公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

木材流通DXのB2B SaaS — PostgreSQL を中核にマルチテナント・多段商流を支えたデータ基盤

ケーススタディを見る