2026年5月28日

2026年5月28日

PostgreSQLのJSON・JSONBの活用方法(演算子・更新・GINインデックスの使い分け)

PostgreSQL で JSON データを扱うなら、ほとんどの場面では json 型ではなく jsonb 型を選び、よく検索するキーには GIN インデックスか式インデックスを用意するのが基本です。この記事では、json と jsonb の違い、値の取り出しと検索に使う演算子、部分的な更新のしかたと落とし穴、インデックスの選び方、そして「どこまで JSON に入れてよいか」の判断基準までを、そのまま試せる SQL とともに説明します。

なお、WordPress のコアは MySQL・MariaDB を対象にしており、PostgreSQL は公式にはサポートされていません。この記事は、WordPress と並べて動かす別のアプリケーションや、集計用のデータベースで PostgreSQL を使う場面を想定しています。

json と jsonb の違い

PostgreSQL には JSON 用の型が2つあります。

  • json:入力された文字列をそのまま保存します。空白、キーの順番、重複したキーも残ります。保存は速いものの、検索や取り出しのたびに解析し直すため、読み出しが多い用途には向きません。
  • jsonb:解析済みのバイナリ形式で保存します。空白は除かれ、キーの順番は保持されず、重複したキーは最後の値だけが残ります。そのかわり、検索が速く、GIN インデックスを使えます。

外部 API の応答を「受け取ったまま一字一句残したい」といった特殊な事情がなければ、jsonb を使ってください。以降の例はすべて jsonb です。

CREATE TABLE products (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name  text  NOT NULL,
  attrs jsonb NOT NULL DEFAULT '{}'::jsonb
);

INSERT INTO products (name, attrs) VALUES
  ('Tシャツ', '{"color": "red",  "sizes": ["S", "M", "L"], "price": 1200, "tags": ["sale"]}'),
  ('パーカー', '{"color": "navy", "sizes": ["M", "L"],      "price": 4800}');

不正な JSON を入れようとすると、INSERT の時点でエラーになります。型が JSON の文法を検査してくれる点も、文字列型(text)に JSON を入れる場合との違いです。

値を取り出す演算子

取り出しでいちばん使うのは -> と ->> です。

SELECT
  attrs -> 'sizes'            AS sizes_jsonb,  -- jsonb のまま返す
  attrs ->> 'color'           AS color_text,   -- text で返す
  attrs #>> '{sizes,0}'       AS first_size,   -- パスを指定して text で返す
  (attrs ->> 'price')::int    AS price
FROM products;

つまずきやすいのは型です。->> の結果は text なので、数値として比較・集計するときは ::int や ::numeric で変換します。変換しないまま attrs ->> 'price' > '1000' のように比べると、文字列としての比較になり、'900' が '1000' より大きいと判定されてしまいます。また、値に数値でない文字列が混じっていると、変換の時点でエラーになります。

PostgreSQL 14 以降では、attrs['color'] のような添字の書き方も使えます。ただし結果は jsonb なので、文字列として使うには変換が要ります。チームで書き方を統一しておくと読みやすくなります。

条件で検索する演算子

検索条件では、次の演算子を使い分けます。

-- 含む(@>):color が red の行
SELECT name FROM products WHERE attrs @> '{"color": "red"}';

-- 配列に要素を含む:sizes に "S" がある行
SELECT name FROM products WHERE attrs -> 'sizes' ? 'S';

-- キーの存在(?):tags というキーを持つ行
SELECT name FROM products WHERE attrs ? 'tags';

-- JSON パス(@?):price が 3000 を超える行(PostgreSQL 12 以降)
SELECT name FROM products WHERE attrs @? '$.price ? (@ > 3000)';

@>(含む)は、後で説明する GIN インデックスがそのまま効くため、検索の主力になります。「キーが red と等しい」を attrs ->> 'color' = 'red' と書くこともできますが、この形は GIN インデックスを使いません。インデックスを効かせたいなら @> で書くか、後述の式インデックスを作ります。

PHP の PDO から ? 演算子を使う場合は注意が必要です。? がプレースホルダーとして解釈されるため、PHP 7.4 以降では ?? と書いて回避するか、@> や JSON パスの書き方に置き換えます。

一部だけを更新する

キーの追加・変更には jsonb_set、削除には - 演算子、結合には || を使います。

-- price を変更(第3引数は jsonb。文字列なら '"blue"' のように二重引用符が要る)
UPDATE products SET attrs = jsonb_set(attrs, '{price}', '1500') WHERE id = 1;

-- キーを追加・上書き(右側の値が優先される)
UPDATE products SET attrs = attrs || '{"material": "cotton"}' WHERE id = 1;

-- キーを削除
UPDATE products SET attrs = attrs - 'tags' WHERE id = 1;

-- 配列に要素を追加(tags が無い行でも動くように COALESCE で空配列を補う)
UPDATE products
SET attrs = jsonb_set(attrs, '{tags}', COALESCE(attrs -> 'tags', '[]'::jsonb) || '"new"'::jsonb)
WHERE id = 2;

ここで最も注意したいのは、jsonb_set に渡す新しい値が NULL になると、結果全体が NULL になる点です。アプリケーションから受け取った値をそのまま渡していて、その値が NULL だった場合、列全体が NULL で上書きされます(列に NOT NULL 制約があればエラーで止まります)。PostgreSQL 13 以降には NULL の扱いを指定できる jsonb_set_lax もあります。いずれにせよ、一括更新の前にはバックアップを取り、BEGIN; と ROLLBACK; で囲んで結果を確認してから本番で実行してください。

また、jsonb の一部だけを変更しても、内部では値全体を書き直した新しい行が作られます。大きな JSON の一部を頻繁に更新すると、書き込み量とテーブルの膨張(VACUUM の負担)が増えます。頻繁に変わる値は通常の列に分けるほうが効率的です。

インデックスの選び方

GIN インデックス(含む・キー存在の検索)

-- 既定の演算子クラス(jsonb_ops):@>, ?, ?|, ?&, @?, @@ に効く
CREATE INDEX idx_products_attrs ON products USING GIN (attrs);

-- jsonb_path_ops:@>, @?, @@ だけに効くが、小さく速いことが多い
CREATE INDEX idx_products_attrs_path ON products USING GIN (attrs jsonb_path_ops);

キーの存在検索(?)を使わず、@> が中心なら jsonb_path_ops が候補になります。どちらか一方を作れば十分で、両方作ると書き込みが重くなるだけです。

式インデックス(特定のキーで等価・範囲・並べ替え)

特定のキーで範囲検索や並べ替えをするなら、そのキーを取り出した式に B-tree インデックスを作ります。

CREATE INDEX idx_products_price ON products (((attrs ->> 'price')::numeric));

-- インデックスが効くのは、クエリの式がインデックスと同じ形のとき
SELECT name FROM products WHERE (attrs ->> 'price')::numeric BETWEEN 1000 AND 5000;

クエリ側の書き方がインデックスの式と少しでも違うと使われません。作成後は EXPLAIN で Index Scan や Bitmap Index Scan になっているかを確認します。テーブルが小さいうちは Seq Scan が選ばれることもありますが、それは異常ではありません。本番の大きなテーブルに作る場合は、書き込みを止めない CREATE INDEX CONCURRENTLY を検討してください。

どこまで JSON に入れるか

jsonb は便利ですが、何でも入れると後で困ります。判断の目安は次のとおりです。

  • 通常の列にするもの:主キー・外部キー、ほぼすべての行にある値、頻繁に検索・集計・更新する値、型や制約で守りたい値(価格、在庫数、日付など)
  • jsonb に入れてよいもの:商品カテゴリごとに項目が違う属性、外部サービスから受け取る付加情報、表示用の設定値など、構造が行ごとに変わる値

途中まで jsonb に入れていた値を列に昇格したくなったら、PostgreSQL 12 以降の生成列(GENERATED ALWAYS AS (...) STORED)で JSON から値を取り出した列を作る方法もあります。また、CHECK (jsonb_typeof(attrs -> 'price') = 'number') のような CHECK 制約で、最低限の型を保証することもできます。

PostgreSQL の JSON 関連の機能はバージョンごとに追加されています(2026年10月現在)。たとえば PostgreSQL 16 の IS JSON 述語や、PostgreSQL 17 の JSON_TABLE などの SQL 標準の関数があります。使っているバージョンで利用できるかは、公式ドキュメントの該当バージョンのページで確認してください。

まとめ

  • JSON データには原則として jsonb 型を使う。json は入力をそのまま残したい場合だけ
  • 取り出しは ->(jsonb)と ->>(text)。数値として比べるときは型変換を忘れない
  • 検索の主力は @>。GIN インデックスがそのまま効く
  • jsonb_set に NULL を渡すと列全体が NULL になる。一括更新はバックアップと ROLLBACK での確認を先に
  • 特定のキーで範囲検索・並べ替えをするなら式インデックス。EXPLAIN で使われているかを確認する
  • 頻繁に検索・更新する値や制約で守りたい値は、jsonb ではなく通常の列にする

お気軽にご相談ください

お見積りへ お問い合わせへ
▲