- データベース
PostgreSQL jsonb操作一覧|演算子早見表とGINインデックス実測
PostgreSQLでJSONを扱うときは jsonb 型を使い、->・->> で取り出し、@> で検索し、jsonb_set・||・- で更新します。 jsonb はJSONを分解したバイナリ形式で保存する型で、検索用の演算子とGINインデックスが使えるのが特長です。
この記事では、取得・検索・更新・削除・展開・集約の演算子と関数を、PostgreSQL 16.13 の実行結果つきで一覧にしました。json と jsonb の違いも実行で確かめ、GINインデックスがどの書き方で効くかを50万行で EXPLAIN ANALYZE して実測しています。PG17 以降の JSON_TABLE なども公式に沿って紹介します。
PostgreSQL jsonb 操作の早見表
「やりたいこと → 書き方」をこの表で探してください。 data は jsonb 列です。
| やりたいこと | 書き方 | ポイント |
|---|---|---|
| 値をJSONのまま取り出す | data->'customer' |
結果は jsonb |
| 値を文字列で取り出す | data->>'status' |
結果は text。比較・表示はこちら |
| 配列のn番目(0始まり) | data->'items'->0、後ろから ->-1 |
|
| 深い階層をパスで | data#>'{items,0}' / data#>>'{items,0,sku}' |
jsonb / text |
| 添字で参照・更新(PG14〜) | data['customer']['name'] |
結果は jsonb |
| 含む形で検索 | data @> '{"status":"pending"}' |
GINが効く |
| キー(配列なら要素)があるか | data ? 'coupon' |
jsonb_ops のGINが効く |
| いずれか/すべてのキー | ?| array[...] / ?& array[...] |
|
| jsonpath で条件検索 | data @? '$.items[*] ? (@.price > 1000)' |
GINが効く |
| jsonpath の述語 | data @@ '$.customer.rank == "gold"' |
GINが効く |
| 値を置き換え・キー追加 | jsonb_set(data, '{status}', '"shipped"') |
親キーがないと追加されない |
| 浅くマージ・配列に追加 | data || '{"priority":"high"}' |
入れ子は丸ごと置き換え |
| キー削除 | data - 'coupon' / data - '{a,c}'::text[] |
配列は - 1 で位置 |
| 深い場所を削除 | data #- '{a,b,c}' |
|
| 配列に挿入 | jsonb_insert(data, '{tags,1}', '"b"') |
第4引数 true で後ろへ |
| null のキーを除く | jsonb_strip_nulls(data) |
入れ子も対象 |
| 条件に合う要素を抽出 | jsonb_path_query(data, '$.items[*] ? (@.qty >= 2).sku') |
複数行を返す |
| 配列を行に展開 | jsonb_array_elements(_text)(data->'tags') |
|
| オブジェクトをキーと値に | jsonb_each(_text)(data) / jsonb_object_keys(data) |
|
| 配列のオブジェクトを表に | jsonb_to_recordset(data->'items') AS i(sku text, qty int) |
列名と型を指定 |
| 行を配列・オブジェクトに集約 | jsonb_agg(x ORDER BY ...) / jsonb_object_agg(k, v) |
|
| 値から組み立て | jsonb_build_object / jsonb_build_array / to_jsonb |
|
| JSONとして正しいか(PG16〜) | v IS JSON [OBJECT|ARRAY] [WITH UNIQUE KEYS] |
text に使える |
| 標準SQLで組み立て(PG16〜) | JSON_OBJECT / JSON_ARRAY / JSON_OBJECTAGG / JSON_ARRAYAGG |
既定は json 型 |
| 表に変換・値を取得(PG17〜) | JSON_TABLE / JSON_VALUE / JSON_QUERY / JSON_EXISTS |
PG16では使えない |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL jsonb操作一覧|演算子早見表とGINインデックス実測」
検証環境と使うテーブル
検証は PostgreSQL 16.13(Ubuntu 24.04)です。 2026年9月時点の最新メジャーは 18(18.6)、19 はベータ版で、16 は2028年11月までサポートされます(公式 Versioning Policy)。17 以降の機能はその都度明記します。
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb NOT NULL
);
INSERT INTO orders (data) VALUES
('{"customer":{"name":"佐藤","rank":"gold"},"status":"shipped","items":[{"sku":"A-1","qty":2,"price":1200},{"sku":"B-7","qty":1,"price":800}],"tags":["gift","express"],"note":null}'),
('{"customer":{"name":"鈴木","rank":"silver"},"status":"pending","items":[{"sku":"A-1","qty":1,"price":1200}],"tags":["express"]}'),
('{"customer":{"name":"高橋","rank":"gold"},"status":"pending","items":[{"sku":"C-3","qty":5,"price":300}],"tags":[],"coupon":"WELCOME"}');
psql は \pset null '(null)' を設定しています。SQL中の -- → は PG16.13 で実際に返った結果です。
json と jsonb の違いを実行で確認
json は入力文字列をそのまま保存し、jsonb は分解・正規化して保存します。
SELECT '{"b":1, "a":2, "a":3}'::json AS json_val,
'{"b":1, "a":2, "a":3}'::jsonb AS jsonb_val;
SELECT '{"x": [1, 2, 3]}'::jsonb; -- → {"x": [1, 2, 3]}
SELECT '{"a":1,"b":2}'::jsonb = '{"b":2, "a":1}'::jsonb; -- → t
SELECT '{"a":1}'::json = '{"a":1}'::json;
json_val | jsonb_val
-----------------------+------------------
{"b":1, "a":2, "a":3} | {"a": 3, "b": 1}
ERROR: operator does not exist: json = json
| 観点 | json | jsonb |
|---|---|---|
| 保存形式 | 入力テキストそのまま | 分解したバイナリ |
| キーの順序 | 入力順 | 保持しない(短いキー順→辞書順) |
| 重複キー | すべて残る | 最後の値だけ残る |
| 空白 | そのまま | 除去 |
= 比較 |
できない(エラー) | できる(順序違いも等しい) |
@> ? @? とGIN |
なし | あり |
迷ったら jsonb で大丈夫です(公式ドキュメントも jsonb を推奨)。json は原文を一字一句残したいときに使います。
値を取り出す(-> ->> #> #>> と添字)
-> はJSONのまま、->> は text で返します。 中をたどるときは ->、最後の比較・表示は ->> です。
SELECT id,
data->'customer'->>'name' AS name,
pg_typeof(data->'status') AS t1,
pg_typeof(data->>'status') AS t2,
data#>>'{items,0,sku}' AS first_sku,
data->'items'->-1->>'sku' AS last_sku,
data['customer']['rank'] AS rank_sub
FROM orders ORDER BY id;
id | name | t1 | t2 | first_sku | last_sku | rank_sub
----+------+-------+------+-----------+----------+----------
1 | 佐藤 | jsonb | text | A-1 | B-7 | "gold"
2 | 鈴木 | jsonb | text | A-1 | A-1 | "silver"
3 | 高橋 | jsonb | text | C-3 | C-3 | "gold"
JSONの null は -> だと jsonb の null、->> だと SQL の NULL。ないキーはどちらも SQL の NULL です。
SELECT data->>'note' AS note, data->'note' AS note_json, data->>'nokey' AS nokey
FROM orders WHERE id = 1;
note | note_json | nokey
--------+-----------+--------
(null) | null | (null)
「キーがない」と「値が null」を区別したいときは data ? 'note' を併用します。
取り出しでよくあるエラーと直し方
| 書いたSQL | PG16の結果 | 直し方 |
|---|---|---|
data->'status' = 'shipped' |
ERROR: invalid input syntax for type json DETAIL: Token "shipped" is invalid. |
data->>'status' = 'shipped' か data->'status' = '"shipped"' |
data->'items'->>'qty'(items は配列) |
エラーにならず NULL | data->'items'->0->>'qty' か展開関数 |
(data->'qty')::int(値が null) |
ERROR: cannot cast jsonb null to type integer |
(data->>'qty')::int(NULLになる)。PG18 からは NULL を返す仕様 |
(data->'qty')::int(値が "5") |
ERROR: cannot cast jsonb string to type integer |
(data->>'qty')::int |
計算に使うときは (data->>'qty')::int の形に揃えると安全です。
検索する(@> <@ ? ?| ?&)
「この形を含む行」を探す @>(包含)が jsonb 検索の基本です。 入れ子や配列の要素にも使え、GINインデックスが効きます。
SELECT id FROM orders WHERE data @> '{"status":"pending"}'; -- → 2, 3
SELECT id FROM orders WHERE data @> '{"customer":{"rank":"gold"}}'; -- → 1, 3
SELECT id FROM orders WHERE data @> '{"items":[{"sku":"A-1"}]}'; -- → 1, 2
SELECT '{"a":1}'::jsonb <@ '{"a":1,"b":2}'::jsonb; -- → t
SELECT id FROM orders WHERE data ? 'coupon'; -- → 3
SELECT id FROM orders WHERE data->'tags' ? 'gift'; -- → 1
SELECT id FROM orders WHERE data->'tags' ?| array['gift','express']; -- → 1, 2
SELECT id FROM orders WHERE data->'tags' ?& array['gift','express']; -- → 1
配列の @> は、items のどれか1件が sku: A-1 を持てばヒットします。
?をプレースホルダーに使うドライバー(JDBCなど)では??と書くかjsonb_exists(data, 'coupon')を使います(関数形式はGINが効きません)。
jsonpath で条件検索・抽出する
「配列の中に price が1000を超える要素がある」のような条件は jsonpath で書きます。 $ がルート、[*] が全要素、? (...) がフィルター、@ が今の要素です。
SELECT id, jsonb_path_query(data, '$.items[*] ? (@.qty >= 2).sku') AS sku
FROM orders ORDER BY id; -- → 1 "A-1" / 3 "C-3"
SELECT id FROM orders WHERE data @? '$.items[*] ? (@.price > 1000)'; -- → 1, 2
SELECT id FROM orders WHERE jsonb_path_exists(data, '$.items[*] ? (@.price > 1000)'); -- → 1, 2
SELECT id FROM orders WHERE data @@ '$.customer.rank == "gold"'; -- → 1, 3
SELECT jsonb_path_query('{"a":[1,2,3,4]}', '$.a[*] ? (@ > $min)', '{"min":2}'); -- → 3, 4(2行)
SELECT id, jsonb_path_query_array(data, '$.items[*].sku') AS skus,
jsonb_path_query_first(data, '$.items[*].price') AS first_price
FROM orders ORDER BY id;
id | skus | first_price
----+----------------+-------------
1 | ["A-1", "B-7"] | 1200
2 | ["A-1"] | 1200
3 | ["C-3"] | 300
更新・削除する(jsonb_set / || / - / #- / jsonb_insert)
jsonb は「新しい値を作って列を置き換える」形で更新します。 まず関数単体の動きです。
SELECT jsonb_set('{"a":{"b":1}}', '{a,b}', '99'); -- → {"a": {"b": 99}}
SELECT jsonb_set('{"a":{}}', '{a,new}', '"v"'); -- → {"a": {"new": "v"}}
SELECT jsonb_set('{"a":{}}', '{x,y}', '1'); -- → {"a": {}}(親 x がないので何もしない)
SELECT '{"a":1}'::jsonb || '{"b":2}'; -- → {"a": 1, "b": 2}
SELECT '{"a":{"x":1}}'::jsonb || '{"a":{"y":2}}'; -- → {"a": {"y": 2}}(浅いマージ)
SELECT '{"a":1,"b":2,"c":3}'::jsonb - 'b'; -- → {"a": 1, "c": 3}
SELECT '["x","y","z"]'::jsonb - 1; -- → ["x", "z"]
SELECT '{"a":{"b":{"c":1,"d":2}}}'::jsonb #- '{a,b,c}'; -- → {"a": {"b": {"d": 2}}}
SELECT jsonb_insert('{"tags":["a","c"]}', '{tags,1}', '"b"'); -- → {"tags": ["a", "b", "c"]}
SELECT jsonb_insert('{"tags":["a","c"]}', '{tags,-1}', '"z"', true); -- → {"tags": ["a", "c", "z"]}
SELECT jsonb_strip_nulls('{"a":1,"b":null,"c":{"d":null}}'); -- → {"a": 1, "c": {}}
ポイントは3つです。
jsonb_setは途中の親キーがないと何もしません。 親ごと作るなら||で先に足します。||は浅いマージです。 入れ子の1キーだけ変えるならjsonb_setを使います。- 新しい値は jsonb で渡します。 文字列は
'"shipped"'のようにダブルクォートを含めるか、to_jsonb('shipped'::text)を使います。
UPDATE のコピペ集
BEGIN;
UPDATE orders SET data = jsonb_set(data, '{status}', '"shipped"') WHERE id = 2
RETURNING data->>'status'; -- → shipped
UPDATE orders SET data = jsonb_set(data, '{customer,rank}', '"platinum"') WHERE id = 1
RETURNING data->'customer'; -- → {"name": "佐藤", "rank": "platinum"}
UPDATE orders SET data = data || '{"priority":"high"}' WHERE id = 3
RETURNING data->'priority'; -- → "high"
UPDATE orders SET data = data - 'coupon' WHERE id = 3
RETURNING data ? 'coupon'; -- → f
UPDATE orders SET data = jsonb_set(data, '{tags}', (data->'tags') || '"vip"') WHERE id = 3
RETURNING data->'tags'; -- → ["vip"]
UPDATE orders SET data['customer']['rank'] = '"bronze"' WHERE id = 2
RETURNING data['customer']; -- → {"name": "鈴木", "rank": "bronze"}(PG14〜)
ROLLBACK;
jsonb_set に SQL の NULL を渡すと列全体が NULL になる
jsonb_set は引数に SQL の NULL があると結果全体が NULL になります。 SET data = jsonb_set(data, '{a}', $1) の $1 が NULL だと、列の中身がまるごと NULL になります。
SELECT jsonb_set('{"a":1}', '{a}', NULL); -- → (null)
SELECT jsonb_set_lax('{"a":1,"b":2}', '{a}', NULL); -- → {"a": null, "b": 2}
SELECT jsonb_set_lax('{"a":1,"b":2}', '{a}', NULL, true, 'delete_key'); -- → {"b": 2}
NULL になりうる値は jsonb_set_lax(PG13〜)で更新し、列には NOT NULL を付けておくと安心です。
展開する(配列を行に・JSONを表に)
配列を行に分けて JOIN・集計したいときは、展開関数を FROM 句に置きます。
SELECT o.id, i.sku, i.qty, i.price, i.qty * i.price AS subtotal
FROM orders o, jsonb_to_recordset(o.data->'items') AS i(sku text, qty int, price int)
ORDER BY o.id, i.sku;
id | sku | qty | price | subtotal
----+-----+-----+-------+----------
1 | A-1 | 2 | 1200 | 2400
1 | B-7 | 1 | 800 | 800
2 | A-1 | 1 | 1200 | 1200
3 | C-3 | 5 | 300 | 1500
カンマ結合だと、配列が空の行は結果から消えます。 残したいときは LEFT JOIN LATERAL ... ON true、順番は WITH ORDINALITY で取れます。
SELECT o.id, t.tag, t.ord
FROM orders o
LEFT JOIN LATERAL jsonb_array_elements_text(o.data->'tags') WITH ORDINALITY AS t(tag, ord) ON true
ORDER BY o.id, t.ord;
id | tag | ord
----+---------+--------
1 | gift | 1
1 | express | 2
2 | express | 1
3 | (null) | (null)
カンマ結合で書くと id=3("tags":[])の行は出ません。jsonb_each(data->'customer') はキーと値の2列(name / "佐藤" など)を返します。
集約・組み立てる(jsonb_agg / jsonb_object_agg / jsonb_build_object)
行をJSONにまとめて返すときは、集約関数と組み立て関数を組み合わせます。
SELECT jsonb_agg(data->'customer'->>'name' ORDER BY id) FROM orders;
-- → ["佐藤", "鈴木", "高橋"]
SELECT jsonb_object_agg(id, data->>'status') FROM orders;
-- → {"1": "shipped", "2": "pending", "3": "pending"}
SELECT jsonb_build_object('id', id, 'name', data#>>'{customer,name}',
'items', jsonb_array_length(data->'items'))
FROM orders WHERE id = 1;
-- → {"id": 1, "name": "佐藤", "items": 2}
PostgreSQL 16 の SQL/JSON(IS JSON・JSON_OBJECT など)
PostgreSQL 16 で、標準SQLの IS JSON 述語と JSON_OBJECT などの組み立て関数が使えるようになりました。
SELECT v, v IS JSON AS is_json, v IS JSON OBJECT AS is_obj,
v IS JSON ARRAY AS is_arr, v IS JSON WITH UNIQUE KEYS AS uniq
FROM (VALUES ('{"a":1}'), ('[1,2]'), ('{"a":1,"a":2}'), ('abc')) AS t(v);
v | is_json | is_obj | is_arr | uniq
---------------+---------+--------+--------+------
{"a":1} | t | t | f | t
[1,2] | t | f | t | t
{"a":1,"a":2} | t | t | f | f
abc | f | f | f | f
SELECT JSON_OBJECT('id': 1, 'name': '佐藤'); -- → {"id" : 1, "name" : "佐藤"}
SELECT JSON_OBJECT('a': NULL); -- → {"a" : null}
SELECT JSON_ARRAY(1, 'x', NULL); -- → [1, "x"]
SELECT JSON_ARRAY(1, 'x', NULL NULL ON NULL); -- → [1, "x", null]
SELECT JSON_OBJECT('id': 1 RETURNING jsonb); -- → {"id": 1}
既定では json 型で返るので、jsonb が欲しいときは RETURNING jsonb を付けます。 JSON_ARRAY は既定で NULL を省き、JSON_OBJECT は既定で null を入れる点も違います。IS JSON は CHECK (body IS JSON OBJECT) のように制約にも使えます。
PostgreSQL 17 以降の JSON_TABLE・JSON_VALUE(公式準拠)
JSON_TABLE と JSON_VALUE / JSON_QUERY / JSON_EXISTS は PostgreSQL 17 で追加されました。 PG16 では次のエラーになります。
SELECT * FROM JSON_TABLE('[{"a":1}]', '$[*]' COLUMNS (a int PATH '$.a'));
ERROR: syntax error at or near "COLUMNS"
SELECT JSON_VALUE('{"a":1}', '$.a');
ERROR: function json_value(unknown, unknown) does not exist
以下は公式ドキュメントに沿って書いた PG17 以降向けの例で、この記事の環境(PG16)では未実行です。
-- PostgreSQL 17 以降(未実行)
SELECT o.id, jt.*
FROM orders o,
JSON_TABLE(o.data, '$.items[*]'
COLUMNS (
rn FOR ORDINALITY,
sku text PATH '$.sku',
qty int PATH '$.qty',
price numeric PATH '$.price' DEFAULT 0 ON EMPTY
)) AS jt;
| 版 | JSON関連の主な追加(公式リリースノート) |
|---|---|
| 14 | jsonb の添字 data['key'] |
| 16 | IS JSON、JSON_OBJECT JSON_ARRAY と集約版 |
| 17 | JSON_TABLE、JSON_EXISTS JSON_QUERY JSON_VALUE、JSON() など、jsonpath の .integer() .date() など |
| 18 | jsonb の null をスカラー型へキャストすると NULL、jsonb_strip_nulls に配列の null を除く引数 |
GINインデックスが効く書き方・効かない書き方(50万行で実測)
jsonb 列全体の GIN インデックスは @> ? @? @@ に効き、data->>'key' = '値' には効きません。 50万行(テーブル 75MB)で確かめました。
CREATE TABLE events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, data jsonb NOT NULL);
INSERT INTO events (data)
SELECT jsonb_build_object(
'user_id', g % 5000,
'type', (ARRAY['view','click','cart','order'])[1 + g % 4],
'device', CASE WHEN g % 3 = 0 THEN 'ios' WHEN g % 3 = 1 THEN 'android' ELSE 'web' END,
'meta', jsonb_build_object('campaign', 'c' || (g % 200), 'ab', g % 2 = 0))
FROM generate_series(1, 500000) AS g;
UPDATE events SET data = data || '{"coupon":"SPRING"}' WHERE id % 1000 = 0; -- 500行だけ
ANALYZE events;
CREATE INDEX events_data_gin ON events USING gin (data); -- 既定の jsonb_ops
EXPLAIN ANALYZE SELECT count(*) FROM events WHERE data @> '{"meta":{"campaign":"c42"}}';
Aggregate (cost=8350.73..8350.74 rows=1 width=8) (actual time=16.922..16.923 rows=1 loops=1)
-> Bitmap Heap Scan on events (cost=60.79..8338.13 rows=5040 width=0) (actual time=9.449..16.781 rows=2500 loops=1)
Recheck Cond: (data @> '{"meta": {"campaign": "c42"}}'::jsonb)
-> Bitmap Index Scan on events_data_gin (cost=0.00..59.53 rows=5040 width=0) (actual time=8.501..8.501 rows=2500 loops=1)
Execution Time: 16.948 ms
実測まとめ(PG16.13・50万行・各1回の計測なので目安です):
| 条件の書き方 | 索引なし | GIN jsonb_ops | GIN jsonb_path_ops | 式インデックス |
|---|---|---|---|---|
data @> '{"meta":{"campaign":"c42"}}' |
143.6 ms(Seq) | 16.9 ms | 4.1 ms | ― |
data @? '$.meta ? (@.campaign == "c42")' |
― | 8.8 ms | ― | ― |
data ? 'coupon'(500行が該当) |
― | 0.28 ms | 49.4 ms(Seq、非対応) | ― |
data->'meta'->>'campaign' = 'c42' |
― | 96.1 ms(Seq) | ― | 2.97 ms |
data#>>'{meta,campaign}' = 'c42' |
― | ― | ― | 98.8 ms(Seq、式が違う) |
ほぼ全行が該当する data ? 'device' では、GINがあっても Seq Scan が選ばれました(120 ms)。
jsonb_ops と jsonb_path_ops の選び方
| 観点 | jsonb_ops(既定) | jsonb_path_ops |
|---|---|---|
| 作り方 | USING gin (data) |
USING gin (data jsonb_path_ops) |
| 対応演算子 | @> ? ?| ?& @? @@ |
@> @? @@(? 系は非対応) |
| サイズ(今回) | 9,656 kB | 6,432 kB |
@> の速さ(今回) |
16.9 ms | 4.1 ms |
@> 中心なら jsonb_path_ops、? も使うなら jsonb_ops が目安です。
特定キーの =・範囲検索には式インデックス
よく使うキーが決まっているなら、そのキーの B-tree 式インデックスが小さく速く、範囲検索や ORDER BY にも使えます。
CREATE INDEX events_campaign_idx ON events ((data->'meta'->>'campaign'));
CREATE INDEX events_user_idx ON events (((data->>'user_id')::int)); -- 数値で比較
EXPLAIN (ANALYZE, COSTS OFF) SELECT count(*) FROM events WHERE (data->>'user_id')::int = 123;
Aggregate (actual time=0.874..0.875 rows=1 loops=1)
-> Bitmap Heap Scan on events (actual time=0.068..0.861 rows=100 loops=1)
Recheck Cond: (((data ->> 'user_id'::text))::integer = 123)
-> Bitmap Index Scan on events_user_idx (actual time=0.052..0.053 rows=100 loops=1)
Execution Time: 0.896 ms
式インデックスは、WHERE の式が定義と同じ形のときだけ使われます。 上の表のとおり、-> ->> で作ったインデックスは、同じ意味の #>> の条件には使われませんでした。チームで書き方を揃えましょう。
MySQL の JSON 型との対応表
MySQL 8.0 の JSON 型もバイナリ保存で、重複キーは最後の値が残ります(MySQL 8.0.46 で {"b":1,"a":2,"a":3} が {"a": 3, "b": 1} で保存されることを確認)。
| やりたいこと | PostgreSQL jsonb | MySQL 8.0 JSON |
|---|---|---|
| 取り出す(JSON / 文字列) | data->'a' / data->>'a' |
data->'$.a' / data->>'$.a' |
| 含むか / キーがあるか | @> / ? |
JSON_CONTAINS / JSON_CONTAINS_PATH |
| 値を設定 / キー削除 | jsonb_set / data - 'a' |
JSON_SET / JSON_REMOVE |
| 表に展開 / 集約 | jsonb_to_recordset / jsonb_agg |
JSON_TABLE / JSON_ARRAYAGG |
| インデックス | GIN、式インデックス | 生成列+索引、Multi-Valued Index |
MySQL の ->> は列にだけ書けるため、JSON_SET(...)->>'$.b' は ERROR 1064 でした。関数の結果には JSON_UNQUOTE(JSON_EXTRACT(...)) を使います。
jsonb にするか、普通の列にするかの判断基準
jsonb は柔軟ですが、型チェックや外部キーは使えません。 次の表で判断してください。
| データの性質 | おすすめ | 理由 |
|---|---|---|
| どの行にも必ずある・JOINや集計で使う項目 | 普通の列 | NOT NULL・型・CHECK、安定した実行計画 |
| 他テーブルを参照する(会員ID・商品ID) | 普通の列+外部キー | 整合性をDBが守る |
| 項目が行ごとに違う(商品ごとの属性、設定値) | jsonb | 列を増やさず持てる |
| 外部APIの応答・Webhook本文 | jsonb | 形が変わっても保存できる |
| 途中から検索が増えた項目 | 生成列・式インデックス、のちに普通の列へ | 段階的に性能を上げられる |
途中から検索が増えた項目は、生成列で普通の列として取り出せます(PG16で実行確認済み)。
ALTER TABLE orders ADD COLUMN status text GENERATED ALWAYS AS (data->>'status') STORED;
CREATE INDEX orders_status_idx ON orders (status);
よくある質問
Q. json と jsonb はどちらを使えばよいですか? A. 検索や更新をするなら jsonb です。jsonb は重複キーの最後の値だけを残し、キーの順序も変わる点に注意します。
Q. data->>'status' = 'pending' にインデックスを効かせるには?
A. CREATE INDEX ON orders ((data->>'status')); の式インデックスを作ります。列全体のGINは ->> の = には使われません。GINを使うなら data @> '{"status":"pending"}' と書きます。
まとめ
- 検索・更新するなら jsonb。 json はキーの順序・重複・空白をそのまま残す型です。
- 取り出しは
->(jsonb)と->>(text)、比較と計算は->>とキャストで行います。 - 検索は
@>が基本、複雑な条件は jsonpath の@?@@。 どちらも GIN が効きます。 - 更新は
jsonb_set||-#-。 SQLの NULL で列全体が NULL になるのでjsonb_set_laxを活用します。 - GIN は jsonb_path_ops(
@>中心で小さく速い)と jsonb_ops(?も使う)を使い分け、->>の=には式インデックスを作ります。 - PG16 で IS JSON・JSON_OBJECT など、PG17 で JSON_TABLE・JSON_VALUE などが使えます。
- 必ずある項目・JOINする項目は普通の列、行ごとに形が違う項目は jsonb が判断の目安です。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「PostgreSQL jsonb操作一覧|演算子早見表とGINインデックス実測」
株式会社RJCでは、データベースの設計や実行計画の確認を、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。