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

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

- 公開日: 2026-06-22
- 著者: 友田 陽大
- タグ: PostgreSQL, アーキテクチャ設計, TypeScript
- URL: https://tomodahinata.com/blog/postgresql-jsonb-operators-gin-index-design-guide

## 要点

- 原則 jsonb を使う（json はテキストを保持し毎回再パース、jsonb は分解バイナリで処理が速く索引も張れる）。jsonb はキー順・空白を保持せず、重複キーは最後が勝つ
- アクセスは -> が JSON型・->> がテキスト、#>/#>> がパス、data['key'] が添字（PG14+）。包含 @> は『順序を無視』で構造一致、存在 ? はトップレベルのキーだけ見る（ネストは対象外）という落とし穴に注意
- 索引の判断：『どんなキー・包含でも柔軟に検索』なら GIN。既定 jsonb_ops は ? ?& ?| @> @? @@ に対応、jsonb_path_ops は @> @? @@ だけだが小さく速い。『1つの決まったキーを等値/範囲』なら式B-tree（col->>'field'）が安い
- 更新は jsonb_set / || / - / #-。jsonb は行ロックが行全体に効くため、巨大ドキュメントは更新競合の原因。業務上分割不能な原子データに留める
- PostgreSQL 18 では jsonb の null をスカラ型に NULL としてキャスト可、json_strip_nulls に配列の null も除去するオプション、長いJSONのSIMD高速化が追加

---

「スキーマが固まらない」「設定や属性が可変」——そんなとき、専用の NoSQL を増やす前に、PostgreSQL の **JSONB** を思い出してください。RDB のトランザクション・結合・制約を保ったまま、**半構造データを1つの列に格納し、索引まで張れます**。

ただし JSONB は「何でも入れていい便利な袋」ではありません。**正規化列との使い分け**、**包含・存在演算子の落とし穴**、そして**索引設計の判断**を誤ると、遅くて壊れやすいテーブルになります。この記事は、JSONB を本番品質で使うための判断基準を、PostgreSQL 公式ドキュメントに忠実に整理します。[データベース・RLS クラスタ](/blog/category/database)とも地続きの、実務テーマです。

> **この記事のルール**：型の挙動・演算子・索引・関数・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は保持）。

```sql
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 のみ。代入も可） |

```sql
-- 比較・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回として扱う」。

```sql
-- オブジェクトの部分一致：tags に "pg" を含み plan が "pro" の行
SELECT * FROM docs WHERE data @> '{"plan":"pro"}';
SELECT * FROM docs WHERE data @> '{"tags":["pg"]}';   -- 配列の順序は無視される
```

**落とし穴**：`@>` は**ネストを平坦化しません**。公式の例：

```sql
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` 値の**トップレベル**にキーまたは配列要素として現れるかを問う。**ネストしたキーは見ない**」。

```sql
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 上は新版を作る点に注意）。

```sql
-- 値の差し替え／追加（既定で 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 オブジェクトに）。

```sql
-- 行を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）と `@@`（パス述語の真偽）。

```sql
-- 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 の配列をリレーショナルに正規化したいときの強力な道具です。

```sql
-- 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` 索引より**通常ずっと小さく**、検索の特異性も高く（頻出キーを含むクエリで特に）、**検索性能は既定クラスより良いのが普通**」。

```sql
-- 万能（存在 ? も使いたい）：既定の 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 が小さく速い**。

```sql
-- 単一キーの等値/範囲検索：式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 任せにする |

```sql
-- 良い例：検索/結合/制約に使う列は正規化、可変の付帯情報だけ 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 する」。

```ts
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つのテーブルで両立させる道具です。次は、超大規模テーブルを物理的に分割する [宣言的パーティショニング](/blog/postgresql-declarative-partitioning-range-list-hash-guide) へ。

---

### 参考（PostgreSQL 18 公式ドキュメント）

- [8.14. JSON Types（json vs jsonb・包含/存在・索引）](https://www.postgresql.org/docs/18/datatype-json.html)
- [9.16. JSON Functions and Operators](https://www.postgresql.org/docs/18/functions-json.html)
- [8.14.4. jsonb Indexing](https://www.postgresql.org/docs/18/datatype-json.html#JSON-INDEXING)
- [E.4. Release 18（JSON 関連の変更）](https://www.postgresql.org/docs/18/release-18.html)
