jsonb_set と連結 — JSONの一部だけを書き換える
JSONの一部だけを差し替えるには jsonb_set(対象, パス, 新しい値) を使います。トップレベルへのキー追加・上書きは連結演算子 || が簡潔です。どちらも新しい jsonb を返すだけで、元の値は書き換えません。
SELECT jsonb_set(jsonb_col, '{parent, child}', 'false'::jsonb) AS replaced, -- 指定パスを差し替え jsonb_col || '{"key": "value"}' AS merged -- 同名キーは右が優先 FROM table_name;
jsonb_set が作れるのは最後の1段だけです。'{notify, email}' の notify 自体が存在しない行では、エラーにも警告にもならず、対象がそのまま返ります。user_settings テーブルの全行について、notify.email を false に変更し、さらにトップレベルへ "theme": "dark" を設定した結果を求めてください。取得列は user_id, settings、user_id 昇順で返してください。
| user_id | settings(jsonb) |
|---|---|
| 1 | {"theme": "light", "notify": {"push": false, "email": true}} |
| 2 | {"notify": {"push": true, "email": true}} |
| 3 | {"theme": "light"} |
| user_id | settings |
|---|---|
| 1 | {"theme": "dark", "notify": {"push": false, "email": false}} |
| 2 | {"theme": "dark", "notify": {"push": true, "email": false}} |
| 3 | {"theme": "dark"} |
SELECT user_id, jsonb_set(settings, '{notify, email}', 'false'::jsonb) -- notify がある行だけ差し替わる || '{"theme": "dark"}' AS settings -- トップレベルは上書き or 追加 FROM user_settings ORDER BY user_id; /* 実行順序(SQLの論理的な評価順): 1. FROM user_settings → 3行読み込み 2. SELECT jsonb_set(...) → notify.email を false へ差し替え 3. SELECT ... || '{"theme"...}' → theme を上書き or 追加 4. ORDER BY user_id → user_id の昇順 */
LEGEND
① FROM user_settings
FROM user_settingsuser_settings 全3行を読み込みます。user 2 には theme が無く、user 3 には notify がありません。jsonb 列なので、キーは長さ順・バイト順に並び替えられた状態で保持されています。| user_id | settings(jsonb) |
|---|---|
| 1 | {"theme": "light", "notify": {"push": false, "email": true}} |
| 2 | {"notify": {"push": true, "email": true}} |
| 3 | {"theme": "light"} |
jsonb_set、トップレベルのキーを足す・置くなら ||。|| は浅いマージで、同名キーは中身を混ぜずに丸ごと置き換わります。settings - 'theme'(トップレベル)や settings #- '{notify, push}'(パス指定)で書きます。関数ではなく演算子である点が、追加・更新と非対称です。jsonb の出力は入力順ではありません。theme が notify より先に出るのはキー長が短いためで、値の意味には関係しません。UPDATE の更新件数は3件と報告されるため、集計するまで気付けない欠落になります。必要なら WHERE settings ? 'notify' で対象を明示します。UPDATE する実装は、同時実行で片方の更新が消えます。jsonb_set はサーバ側の1文で完結するので、この競合が起きません。jsonb_build_object と jsonb_agg — 行からJSONを組み立てる
行をJSONへ変換するには jsonb_build_object(キー, 値, キー, 値, …) を使います。引数はキーと値の交互で、奇数個だとエラーになります。グループ内の値を1つのJSON配列にまとめるのは集約関数 jsonb_agg です。
SELECT jsonb_agg( jsonb_build_object('key1', col1, 'key2', col2) ORDER BY sort_col -- 配列の並びを固定する ) AS items FROM table_name GROUP BY group_col;
to_jsonb(t) が使えます。列の追加が自動で反映される反面、内部列まで公開されるため、外部へ返す形は jsonb_build_object で明示するほうが安全です。sales テーブルを地域ごとにまとめ、商品と金額の組をJSON配列にしてください。配列の要素は {"product": …, "amount": …} の形で、sale_id の昇順に並べます。取得列は region, items、region 昇順で返してください。
| sale_id | region | product | amount |
|---|---|---|---|
| 1 | East | マウス | 1500 |
| 2 | East | キーボード | 3000 |
| 3 | West | モニター | 20000 |
| region | items |
|---|---|
| East | [{"amount": 1500, "product": "マウス"}, {"amount": 3000, "product": "キーボード"}] |
| West | [{"amount": 20000, "product": "モニター"}] |
SELECT region, jsonb_agg( jsonb_build_object('product', product, 'amount', amount) ORDER BY sale_id -- 配列の並びを一意に決める ) AS items FROM sales GROUP BY region ORDER BY region; /* 実行順序(SQLの論理的な評価順): 1. FROM sales → 3行読み込み 2. GROUP BY region → 2グループに分割 3. SELECT jsonb_build_object → 行ごとにJSONオブジェクトを作成 4. SELECT jsonb_agg(... ORDER BY sale_id) → グループごとに配列へ集約 5. ORDER BY region → region の昇順 */
LEGEND
① FROM sales
FROM salessales 全3行を読み込みます。East に2行、West に1行が属します。| sale_id | region | product | amount |
|---|---|---|---|
| 1 | East | マウス | 1500 |
| 2 | East | キーボード | 3000 |
| 3 | West | モニター | 20000 |
jsonb_agg(expr ORDER BY sort_col) は集約関数専用の構文で、配列内の並びを決めます。書かなければ順序は保証されず、実行計画が変わった日に配列の並びが変わります。amount が先に来るのは、jsonb がキー長→バイト順で並べるためです。順序を保ちたいときだけ json_build_object(jsonb ではなく json)を使います。jsonb_build_object や jsonb_agg を置けば、入れ子のレスポンスを1クエリで組み上げられます。アプリ側でループして組み立てる処理をSQLへ寄せられます。'{"product": "' || product || '"}' は、値に引用符や改行が入った瞬間に壊れたJSONを生成します。エスケープは組み立て関数に任せます。jsonb_agg は NULL を返します。受け取り側で空配列を期待しているなら COALESCE(jsonb_agg(…), '[]'::jsonb) を既定形にします。jsonb_each — オブジェクトのキーと値を行に開く
キー名が事前に分からないオブジェクトは、jsonb_each でキーと値の組を行として取り出します。key と value の2列を返す集合返し関数で、FROM 句で使います。
SELECT kv.key, kv.value FROM table_name t CROSS JOIN LATERAL jsonb_each_text(t.jsonb_col) AS kv; -- jsonb_each : value は jsonb(文字列は引用符付き) -- jsonb_each_text : value は text(引用符なし) -- jsonb_object_keys : キー名だけが欲しいとき
jsonb_each を適用します。configs テーブルの params を、1キー1行の縦持ちに展開してください。取得列は config_id, key, value、config_id 昇順・key 昇順で返してください。value は引用符の付かないテキストにします。
| config_id | params(jsonb) |
|---|---|
| 1 | {"debug": "true", "limit": "100"} |
| 2 | {"limit": "50"} |
| config_id | key | value |
|---|---|---|
| 1 | debug | true |
| 1 | limit | 100 |
| 2 | limit | 50 |
SELECT c.config_id, kv.key, kv.value FROM configs c CROSS JOIN LATERAL jsonb_each_text(c.params) AS kv -- value をテキストで受け取る ORDER BY c.config_id, kv.key; /* 実行順序(SQLの論理的な評価順): 1. FROM configs c → 2行読み込み 2. CROSS JOIN LATERAL jsonb_each_text → キーごとに展開して3行へ 3. SELECT config_id, key, value → 3列を選択 4. ORDER BY c.config_id, kv.key → config_id 昇順・key 昇順 */
LEGEND
① FROM configs
FROM configs cconfigs 全2行を読み込みます。config 1 は2キー、config 2 は1キーを持ちます。キー名はテーブル定義には現れません。| config_id | params(jsonb) |
|---|---|
| 1 | {"debug": "true", "limit": "100"} |
| 2 | {"limit": "50"} |
->> はキー名を書く必要がありますが、jsonb_each は書かずに全キーを取り出せます。設定値のように項目が増減するデータの棚卸しに向きます。jsonb_each の value は jsonb なので、文字列は "true" と引用符付きです。そのまま表示・比較するなら jsonb_each_text を選びます。ORDER BY でキーと親の識別子を必ず指定します。jsonb_each で全キーを開いてから WHERE key = 'limit' で絞るのは、params ->> 'limit' と同じ結果を高いコストで得ているだけです。キー名が分かっているなら直接取り出します。value に入ります。jsonb_each_text ではそれがJSONテキストとして返るため、さらに解析が必要になります。jsonb_each は、そういうデータを調べるための道具として使うのが健全で、日常の参照経路にするなら列へ切り出す合図と読みます。jsonb_typeof — JSON null・キー欠損・列のNULLを見分ける
JSONを扱うと「値が無い」状態が3種類に分かれます。値が JSON の null、キー自体が無い、列そのものが SQL NULL。->> はどれも SQL NULL を返すため、区別には jsonb_typeof を使います。
SELECT jsonb_typeof(jsonb_col -> 'key') AS json_type FROM table_name; -- 'string' / 'number' / 'boolean' / 'object' / 'array' : 値がある -- 'null' : 値が JSON の null -- SQL NULL: キーが無い、または列が NULL
jsonb_col ? 'key' は「キーがあるか」だけを見るので、値が JSON null でも真になります。「キーはあるが値が null」を通したいのか弾きたいのかで、使う判定が変わります。profiles テーブルの各行について、nickname の値とJSON上の型を並べてください。取得列は profile_id, nickname, json_type、profile_id 昇順で返してください。絞り込みは行いません。
| profile_id | data(jsonb) |
|---|---|
| 1 | {"nickname": "たろう"} |
| 2 | {"nickname": null} |
| 3 | {"age": 20} |
| 4 | NULL |
| 5 | {"nickname": "はなこ"} |
| profile_id | nickname | json_type |
|---|---|---|
| 1 | たろう | string |
| 2 | NULL | null |
| 3 | NULL | NULL |
| 4 | NULL | NULL |
| 5 | はなこ | string |
SELECT profile_id, data ->> 'nickname' AS nickname, -- 3状態とも SQL NULL jsonb_typeof(data -> 'nickname') AS json_type -- JSON null だけ 'null' 文字列 FROM profiles ORDER BY profile_id; /* 実行順序(SQLの論理的な評価順): 1. FROM profiles → 5行読み込み 2. SELECT ->> で値を取り出し → 3状態とも SQL NULL になる 3. SELECT jsonb_typeof で型判定 → JSON null と欠損を区別 4. ORDER BY profile_id → profile_id の昇順 */
LEGEND
① FROM profiles
FROM profilesprofiles 全5行を読み込みます。profile 2 は値が JSON の null、profile 3 は nickname キーそのものが無く、profile 4 は data 列が SQL NULL です。| profile_id | data(jsonb) |
|---|---|
| 1 | {"nickname": "たろう"} |
| 2 | {"nickname": null} |
| 3 | {"age": 20} |
| 4 | NULL |
| 5 | {"nickname": "はなこ"} |
->> の結果だけを見ると全部同じに見えますが、意味は「未設定と明示した」「項目が無い」「レコードごと無い」でそれぞれ違います。jsonb_typeof が返すのは型名のテキストです。jsonb_typeof(…) = 'null' という文字列比較で判定し、IS NULL と混同しないようにします。WHERE jsonb_typeof(data -> 'nickname') = 'string' と書きます。この例では profile 1 と profile 5 が残ります。data -> 'nickname' IS NULL は profile 3・4 だけに当たり、値が JSON null の profile 2 は当たりません。data -> 'nickname' は jsonb の null という値であって、SQL NULL ではないためです。{"nickname": ""} と入れると、型は string のまま4つ目の状態が増えます。未設定はキーを置かないか JSON null にする、と最初に決めて揃えます。NOT NULL ひとつで「値が無い行」を排除できますが、JSONの内側にはそれが届きません。必須項目が欠けた行は、投入時ではなく数か月後の集計で見つかります。対策は2通りで、必須項目は列へ出すか、CHECK (data ? 'nickname') のような制約をテーブルに足すこと。どちらも取らないなら、少なくとも jsonb_typeof による棚卸しクエリを定期的に回して、欠損の割合を見えるようにしておきます。包含演算子とGIN — ネストした条件を索引の効く形で書く
JSONの絞り込みは、書き方によって索引が効くかどうかが変わります。jsonb 列へ GIN インデックスを張ると、@>・?・?|・?& が索引で処理されます。->> の等価比較はこの索引では加速しません。
CREATE INDEX ON table_name USING GIN (jsonb_col); SELECT * FROM table_name WHERE jsonb_col @> '{"key": "value", "parent": {"child": "value2"}}'; -- 探したい構造をそのまま書けば、ネストしていても1つの条件で済む
@> の右辺はオブジェクト全体を部分集合として判定するため、条件が増えても演算子は1つのままです。AND で分けるより索引が使いやすくなります。events テーブルから、type が purchase で、かつ user.plan が pro のイベントを取得してください。GINインデックスが使えるよう、条件は包含演算子ひとつで書いてください。取得列は event_id, amount、event_id 昇順で返してください。amount は数値にします。
| event_id | payload(jsonb) |
|---|---|
| 1 | {"type": "purchase", "user": {"plan": "pro"}, "amount": 1200} |
| 2 | {"type": "purchase", "user": {"plan": "free"}, "amount": 500} |
| 3 | {"type": "login", "user": {"plan": "pro"}} |
| 4 | {"type": "purchase", "user": {"plan": "pro"}, "amount": 3000} |
| event_id | amount |
|---|---|
| 1 | 1200 |
| 4 | 3000 |
SELECT event_id, (payload ->> 'amount')::int AS amount FROM events WHERE payload @> '{"type": "purchase", "user": {"plan": "pro"}}' -- 2条件を1つの包含で ORDER BY event_id; /* 実行順序(SQLの論理的な評価順): 1. FROM events → 4行読み込み(GIN索引があれば候補行だけ) 2. WHERE payload @> '{...}' → 部分構造を含む2行に絞り込み 3. SELECT (payload->>'amount')::int → 2列を選択 4. ORDER BY event_id → event_id の昇順 */
LEGEND
① FROM events
FROM eventsevents 全4行を読み込みます。payload は type・user・amount を持ち、user はネストしたオブジェクトです。event 3 には amount がありません。| event_id | payload(jsonb) |
|---|---|
| 1 | {"type": "purchase", "user": {"plan": "pro"}, "amount": 1200} |
| 2 | {"type": "purchase", "user": {"plan": "free"}, "amount": 500} |
| 3 | {"type": "login", "user": {"plan": "pro"}} |
| 4 | {"type": "purchase", "user": {"plan": "pro"}, "amount": 3000} |
@> と存在演算子だけです。同じ結果を返す payload ->> 'type' = 'purchase' は、この索引では全件走査になります。jsonb_ops はキーと値の両方を索引に載せ、? 系も使えます。USING GIN (payload jsonb_path_ops) は @> 専用の代わりに索引が小さく高速です。検索が包含だけなら後者を選びます。CREATE INDEX ON events ((payload ->> 'type')) という B-tree の式インデックスが、GINより小さく速くなります。範囲検索もこちらでしか効きません。payload @> '{"type": "purchase"}' AND payload @> '{"user": {"plan": "pro"}}' は結果こそ同じですが、索引の走査が2回に分かれます。1つのオブジェクトへまとめるほうが素直です。@> は完全一致の包含だけで、amount > 1000 のような比較はできません。範囲条件は (payload ->> 'amount')::int と式インデックスの組み合わせになります。fastupdate により挿入時のコストを保留リストへ逃がしていますが、その掃除が走ったタイミングで応答が跳ねます。「とりあえずJSON列にGINを張る」ではなく、実際に流れている検索条件を確かめてから、jsonb_path_ops や式インデックスを含めて選び分けます。