はじめに

リレーショナルデータベースは厳密なスキーマ定義が強みですが、現代のアプリケーション開発では柔軟なデータ構造が求められる場面も少なくありません。ユーザー設定、APIレスポンスのキャッシュ、動的な属性を持つ商品情報など、事前にすべてのカラムを定義することが難しいケースは多々あります。

PostgreSQLのJSONB型は、このような課題を解決する強力なデータ型です。リレーショナルデータベースの堅牢性を維持しながら、NoSQLデータベースのような柔軟性を同時に実現できます。

本記事では、PostgreSQLにおけるJSONB型の基礎から実践的な活用方法までを解説します。JSON型との違い、データ操作の基本、豊富な演算子の使い方、GINインデックスによる高速検索、そしてJSONBを採用すべきケースと避けるべきケースについて、具体的なサンプルコードとともに学んでいきます。

この記事を読むことで、以下のことができるようになります。

  • JSON型とJSONB型の違いを理解し、適切に選択できる
  • JSONBデータの挿入・取得・更新を自在に行える
  • 演算子(->->>@>?)を使いこなせる
  • GINインデックスでJSONB検索を高速化できる
  • JSONBを使うべきケースと避けるべきケースを判断できる

前提条件

  • PostgreSQL 14以降がインストールされていること
  • psqlまたはGUIツール(pgAdmin、DBeaver等)でデータベースに接続できること
  • 基本的なSQL構文(CREATE TABLE、INSERT、SELECT等)を理解していること

JSON型とJSONB型の違い

PostgreSQLには、JSONデータを格納するための2つのデータ型が用意されています。

JSON型の特徴

JSON型は、入力されたJSONテキストをそのまま保存します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- JSON型のテーブル作成
CREATE TABLE json_example (
    id    SERIAL PRIMARY KEY,
    data  JSON
);

-- データ挿入
INSERT INTO json_example (data) VALUES ('{"name": "田中", "age": 30}');

-- 取得すると入力時の形式がそのまま返される
SELECT data FROM json_example;
-- 結果: {"name": "田中", "age": 30}

JSON型の特徴

  • 入力テキストをそのまま保存(ホワイトスペース、キーの順序を保持)
  • 重複キーをすべて保持
  • 読み取り時に毎回パースが必要(処理が遅い)
  • インデックスを作成できない

JSONB型の特徴

JSONB型は、JSONをバイナリ形式に変換して保存します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
-- JSONB型のテーブル作成
CREATE TABLE jsonb_example (
    id    SERIAL PRIMARY KEY,
    data  JSONB
);

-- データ挿入
INSERT INTO jsonb_example (data) VALUES ('{"name": "田中",  "age": 30}');

-- 取得するとキー順がソートされ、余分なスペースが削除される
SELECT data FROM jsonb_example;
-- 結果: {"age": 30, "name": "田中"}

JSONB型の特徴

  • バイナリ形式で保存(変換オーバーヘッドあり)
  • ホワイトスペースは削除、キー順はソート
  • 重複キーは最後の値のみ保持
  • 高速な処理(パース不要)
  • GINインデックスをサポート

JSON型とJSONB型の比較

特徴 JSON JSONB
格納形式 テキスト バイナリ
入力時の変換 なし あり(やや遅い)
読み取り速度 遅い(毎回パース) 高速(パース不要)
ホワイトスペース 保持 削除
キーの順序 保持 ソート
重複キー すべて保持 最後の値のみ
インデックス 不可 GIN対応
等価比較 不可 可能

どちらを選ぶべきか

ほとんどのケースでJSONB型を選択してください。 JSON型を選ぶのは、以下のような特殊なケースに限られます。

  • 入力テキストを一切変更せずに保存する必要がある
  • 重複キーを保持する必要がある
  • データを保存するだけで、検索や加工を行わない
1
2
3
4
5
6
-- 実務では基本的にJSONB型を使用
CREATE TABLE products (
    product_id  SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    attributes  JSONB  -- 動的な属性情報
);

JSONBデータの挿入

JSONBカラムへのデータ挿入は、JSON文字列をそのまま指定できます。

基本的な挿入

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- サンプルテーブルの作成
CREATE TABLE users (
    user_id     SERIAL PRIMARY KEY,
    username    VARCHAR(50) NOT NULL,
    profile     JSONB DEFAULT '{}'::jsonb,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- シンプルなオブジェクトの挿入
INSERT INTO users (username, profile) VALUES
    ('tanaka', '{"age": 30, "city": "東京", "interests": ["読書", "映画"]}');

-- ネストしたオブジェクトの挿入
INSERT INTO users (username, profile) VALUES
    ('suzuki', '{
        "age": 25,
        "city": "大阪",
        "contact": {
            "email": "suzuki@example.com",
            "phone": "090-1234-5678"
        },
        "interests": ["プログラミング", "ゲーム", "音楽"]
    }');

to_jsonb関数を使った挿入

SQLの値をJSONBに変換する場合は、to_jsonb関数を使用します。

1
2
3
4
5
6
7
8
-- レコードをJSONBに変換
SELECT to_jsonb(row('田中太郎', 30, true));
-- 結果: {"f1": "田中太郎", "f2": 30, "f3": true}

-- 型定義付きのレコード変換
CREATE TYPE user_info AS (name TEXT, age INTEGER, active BOOLEAN);
SELECT to_jsonb(ROW('田中太郎', 30, true)::user_info);
-- 結果: {"age": 30, "name": "田中太郎", "active": true}

jsonb_build_object関数を使った挿入

動的にJSONBオブジェクトを構築する場合は、jsonb_build_object関数が便利です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
-- キーと値を交互に指定してオブジェクトを構築
INSERT INTO users (username, profile) VALUES
    ('yamada', jsonb_build_object(
        'age', 35,
        'city', '名古屋',
        'interests', jsonb_build_array('料理', '旅行')
    ));

-- 変数やカラム値からオブジェクトを構築
INSERT INTO users (username, profile)
SELECT
    'sample_user',
    jsonb_build_object(
        'registered_at', CURRENT_TIMESTAMP,
        'source', 'web',
        'preferences', '{}'::jsonb
    );

JSONBデータの取得

JSONBからデータを取得するには、専用の演算子と関数を使用します。

基本的な取得演算子

1
2
-- テストデータの確認
SELECT username, profile FROM users;

->演算子:JSONB値として取得

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
-- キーを指定してJSONB値を取得
SELECT profile -> 'city' FROM users WHERE username = 'tanaka';
-- 結果: "東京"(ダブルクォート付き、JSONB型)

-- ネストしたオブジェクトを取得
SELECT profile -> 'contact' FROM users WHERE username = 'suzuki';
-- 結果: {"email": "suzuki@example.com", "phone": "090-1234-5678"}

-- 配列要素を取得(0始まり)
SELECT profile -> 'interests' -> 0 FROM users WHERE username = 'tanaka';
-- 結果: "読書"

->>演算子:TEXT値として取得

1
2
3
4
5
6
7
-- キーを指定してTEXT値を取得
SELECT profile ->> 'city' FROM users WHERE username = 'tanaka';
-- 結果: 東京(ダブルクォートなし、TEXT型)

-- TEXT型なので文字列比較が可能
SELECT username FROM users WHERE profile ->> 'city' = '東京';
-- 結果: tanaka

パス演算子による深い階層へのアクセス

#>演算子:パス指定でJSONB値を取得

1
2
3
4
5
6
7
-- ネストした値をパスで取得
SELECT profile #> '{contact, email}' FROM users WHERE username = 'suzuki';
-- 結果: "suzuki@example.com"

-- 配列内の要素もパスで指定可能
SELECT profile #> '{interests, 1}' FROM users WHERE username = 'tanaka';
-- 結果: "映画"

#>>演算子:パス指定でTEXT値を取得

1
2
3
-- ネストした値をTEXTとして取得
SELECT profile #>> '{contact, email}' FROM users WHERE username = 'suzuki';
-- 結果: suzuki@example.com

演算子の比較まとめ

演算子 戻り値の型 用途
-> JSONB キーまたはインデックスでJSONB値を取得
->> TEXT キーまたはインデックスでTEXT値を取得
#> JSONB パス配列でJSONB値を取得
#>> TEXT パス配列でTEXT値を取得
1
2
3
4
5
6
7
-- 使い分けの例
SELECT
    profile -> 'age' AS age_jsonb,           -- 30(JSONB型)
    profile ->> 'age' AS age_text,           -- 30(TEXT型)
    (profile ->> 'age')::INTEGER AS age_int  -- 30(INTEGER型)
FROM users
WHERE username = 'tanaka';

JSONBデータの更新

JSONB型のデータを部分的に更新するには、専用の関数と演算子を使用します。

jsonb_set関数による更新

jsonb_set関数は、指定したパスの値を新しい値で置き換えます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
-- 構文: jsonb_set(target, path, new_value [, create_if_missing])

-- 既存のキーの値を更新
UPDATE users
SET profile = jsonb_set(profile, '{age}', '31'::jsonb)
WHERE username = 'tanaka';

-- ネストしたキーの値を更新
UPDATE users
SET profile = jsonb_set(profile, '{contact, email}', '"new@example.com"'::jsonb)
WHERE username = 'suzuki';

-- 新しいキーを追加(create_if_missing = true がデフォルト)
UPDATE users
SET profile = jsonb_set(profile, '{occupation}', '"エンジニア"'::jsonb)
WHERE username = 'tanaka';

-- create_if_missing = false の場合、存在しないキーは追加されない
UPDATE users
SET profile = jsonb_set(profile, '{nickname}', '"たなちゃん"'::jsonb, false)
WHERE username = 'tanaka';
-- nicknameが存在しないため、何も変更されない

連結演算子(||)による更新

||演算子は、2つのJSONBオブジェクトをマージします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- 新しいキーを追加
UPDATE users
SET profile = profile || '{"verified": true}'::jsonb
WHERE username = 'tanaka';

-- 複数のキーを一度に追加・更新
UPDATE users
SET profile = profile || '{"age": 32, "status": "active"}'::jsonb
WHERE username = 'tanaka';

-- 既存のキーは上書きされる
SELECT '{"a": 1, "b": 2}'::jsonb || '{"b": 3, "c": 4}'::jsonb;
-- 結果: {"a": 1, "b": 3, "c": 4}

削除演算子(-)による更新

-演算子は、指定したキーまたは要素を削除します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
-- キーを削除(TEXT指定)
UPDATE users
SET profile = profile - 'occupation'
WHERE username = 'tanaka';

-- 複数のキーを削除(TEXT配列指定)
UPDATE users
SET profile = profile - '{verified, status}'::text[]
WHERE username = 'tanaka';

-- 配列から要素を削除(インデックス指定)
UPDATE users
SET profile = jsonb_set(
    profile,
    '{interests}',
    (profile -> 'interests') - 0  -- 最初の要素を削除
)
WHERE username = 'tanaka';

パス削除演算子(#-)による更新

#-演算子は、パスで指定した要素を削除します。

1
2
3
4
5
6
7
8
9
-- ネストしたキーを削除
UPDATE users
SET profile = profile #- '{contact, phone}'
WHERE username = 'suzuki';

-- 配列内の要素をパスで削除
UPDATE users
SET profile = profile #- '{interests, 1}'
WHERE username = 'suzuki';

添字記法による更新(PostgreSQL 14以降)

PostgreSQL 14以降では、添字記法(サブスクリプティング)でJSONBを更新できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
-- キーの値を直接更新
UPDATE users
SET profile['age'] = '33'
WHERE username = 'tanaka';

-- ネストした値を更新
UPDATE users
SET profile['contact']['email'] = '"updated@example.com"'
WHERE username = 'suzuki';

-- 新しいキーを追加
UPDATE users
SET profile['new_field'] = '"new_value"'
WHERE username = 'tanaka';

JSONB専用演算子

JSONB型には、検索やフィルタリングに使える強力な演算子が用意されています。

包含演算子(@>、<@)

@>演算子は、左辺が右辺を包含しているかを判定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
-- テストデータの準備
CREATE TABLE products (
    product_id  SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    attributes  JSONB
);

INSERT INTO products (name, attributes) VALUES
    ('ノートPC A', '{"brand": "TechCorp", "specs": {"cpu": "i7", "ram": 16, "storage": "512GB SSD"}, "tags": ["business", "portable"]}'),
    ('ノートPC B', '{"brand": "GameTech", "specs": {"cpu": "i9", "ram": 32, "storage": "1TB SSD"}, "tags": ["gaming", "high-performance"]}'),
    ('タブレット C', '{"brand": "TechCorp", "specs": {"cpu": "M1", "ram": 8, "storage": "256GB"}, "tags": ["portable", "creative"]}');

-- 特定のブランドを持つ製品を検索
SELECT name FROM products
WHERE attributes @> '{"brand": "TechCorp"}';
-- 結果: ノートPC A, タブレット C

-- ネストした条件で検索
SELECT name FROM products
WHERE attributes @> '{"specs": {"ram": 16}}';
-- 結果: ノートPC A

-- 配列内の要素を含むか判定
SELECT name FROM products
WHERE attributes @> '{"tags": ["portable"]}';
-- 結果: ノートPC A, タブレット C

-- <@ は逆方向の包含判定
SELECT name FROM products
WHERE '{"brand": "TechCorp"}'::jsonb <@ attributes;
-- 結果: ノートPC A, タブレット C(@>と同じ結果)

存在演算子(?、?|、?&)

キーまたは配列要素の存在を確認します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
-- 特定のキーが存在するか確認(?)
SELECT name FROM products
WHERE attributes ? 'brand';
-- 結果: 全製品

-- 配列内に特定の要素が存在するか確認
SELECT name FROM products
WHERE attributes -> 'tags' ? 'gaming';
-- 結果: ノートPC B

-- いずれかのキーが存在するか確認(?|)
SELECT name FROM products
WHERE attributes -> 'tags' ?| ARRAY['gaming', 'creative'];
-- 結果: ノートPC B, タブレット C

-- すべてのキーが存在するか確認(?&)
SELECT name FROM products
WHERE attributes -> 'tags' ?& ARRAY['portable', 'business'];
-- 結果: ノートPC A

演算子一覧

演算子 説明
@> 左辺が右辺を包含 '{"a":1,"b":2}' @> '{"a":1}' → true
<@ 左辺が右辺に包含される '{"a":1}' <@ '{"a":1,"b":2}' → true
? キー/要素が存在 '{"a":1}' ? 'a' → true
`? ` いずれかのキーが存在
?& すべてのキーが存在 '{"a":1,"b":2}' ?& array['a','b'] → true

GINインデックスによる高速検索

大量のJSONBデータを効率的に検索するには、GIN(Generalized Inverted Index)インデックスが不可欠です。

デフォルトのGINインデックス

1
2
-- デフォルトのGINインデックスを作成
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);

デフォルトのGINインデックスは、以下の演算子をサポートします。

  • ??|?&(キー存在確認)
  • @>(包含確認)
  • @?@@(JSONPath)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- インデックスが使用されるクエリの例
EXPLAIN ANALYZE
SELECT name FROM products
WHERE attributes @> '{"brand": "TechCorp"}';
-- Bitmap Index Scan on idx_products_attributes が使用される

-- キー存在確認もインデックスを使用
EXPLAIN ANALYZE
SELECT name FROM products
WHERE attributes ? 'brand';

jsonb_path_ops演算子クラス

特定の用途では、jsonb_path_ops演算子クラスがより効率的です。

1
2
3
-- jsonb_path_opsインデックスを作成
CREATE INDEX idx_products_attributes_path ON products
USING GIN (attributes jsonb_path_ops);

jsonb_path_opsの特徴

  • @>@?@@演算子のみサポート(?系はサポートしない)
  • インデックスサイズが小さい
  • 検索がより高速(特に深いネスト構造)
1
2
3
-- jsonb_path_opsが有効なクエリ
SELECT name FROM products
WHERE attributes @> '{"specs": {"cpu": "i7"}}';

式インデックス

特定のキーを頻繁に検索する場合は、式インデックスが効果的です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
-- 特定のキーに対するインデックス
CREATE INDEX idx_products_brand ON products
USING GIN ((attributes -> 'tags'));

-- このクエリでインデックスが使用される
SELECT name FROM products
WHERE attributes -> 'tags' ? 'gaming';

-- B-treeインデックスも作成可能(等価比較用)
CREATE INDEX idx_products_brand_btree ON products
    ((attributes ->> 'brand'));

-- 文字列比較でインデックスを使用
SELECT name FROM products
WHERE attributes ->> 'brand' = 'TechCorp';

インデックス選択のガイドライン

flowchart TD
    A[JSONBの検索パターンを分析] --> B{どの演算子を使う?}
    B -->|@> のみ| C[jsonb_path_ops]
    B -->|? ?| ?& も使う| D[デフォルトGIN]
    B -->|特定キーの検索が多い| E[式インデックス]
    C --> F[サイズ小・高速]
    D --> G[汎用性高い]
    E --> H[最も高速だが限定的]

実践的なユースケース

ユーザー設定の管理

ユーザーごとに異なる設定項目を柔軟に保存できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
CREATE TABLE user_settings (
    user_id     INTEGER PRIMARY KEY,
    settings    JSONB DEFAULT '{
        "theme": "light",
        "language": "ja",
        "notifications": {
            "email": true,
            "push": true
        }
    }'::jsonb
);

-- 設定を取得
SELECT settings -> 'theme' AS theme FROM user_settings WHERE user_id = 1;

-- 設定を更新
UPDATE user_settings
SET settings = jsonb_set(settings, '{notifications, push}', 'false'::jsonb)
WHERE user_id = 1;

-- 新しい設定を追加
UPDATE user_settings
SET settings = settings || '{"beta_features": true}'::jsonb
WHERE user_id = 1;

動的属性を持つ商品カタログ

カテゴリによって異なる属性を持つ商品を管理できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE catalog (
    item_id     SERIAL PRIMARY KEY,
    name        VARCHAR(200) NOT NULL,
    category    VARCHAR(50) NOT NULL,
    attributes  JSONB NOT NULL
);

-- 異なる属性を持つ商品を登録
INSERT INTO catalog (name, category, attributes) VALUES
    ('Tシャツ', '衣類', '{"size": "M", "color": "白", "material": "綿100%"}'),
    ('スマートフォン', '電子機器', '{"storage": "128GB", "color": "黒", "os": "Android"}'),
    ('コーヒー豆', '食品', '{"weight": "200g", "origin": "ブラジル", "roast": "中煎り"}');

-- カテゴリ共通の検索(色で検索)
SELECT name, category FROM catalog
WHERE attributes ->> 'color' = '白';

-- カテゴリ固有の検索(ストレージで検索)
SELECT name FROM catalog
WHERE category = '電子機器'
  AND attributes @> '{"storage": "128GB"}';

APIレスポンスのキャッシュ

外部APIのレスポンスを構造を保持したまま保存できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
CREATE TABLE api_cache (
    cache_key   VARCHAR(255) PRIMARY KEY,
    response    JSONB NOT NULL,
    cached_at   TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at  TIMESTAMP NOT NULL
);

-- キャッシュを保存
INSERT INTO api_cache (cache_key, response, expires_at) VALUES
    ('weather_tokyo', '{"temp": 22, "humidity": 60, "conditions": "晴れ"}',
     CURRENT_TIMESTAMP + INTERVAL '1 hour');

-- キャッシュを取得
SELECT response FROM api_cache
WHERE cache_key = 'weather_tokyo'
  AND expires_at > CURRENT_TIMESTAMP;

-- 特定の条件でキャッシュを検索
SELECT cache_key, response ->> 'temp' AS temperature
FROM api_cache
WHERE response @> '{"conditions": "晴れ"}';

イベントログの保存

構造が変化する可能性のあるイベントデータを柔軟に保存できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
CREATE TABLE event_logs (
    log_id      BIGSERIAL PRIMARY KEY,
    event_type  VARCHAR(50) NOT NULL,
    event_data  JSONB NOT NULL,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- インデックスを作成
CREATE INDEX idx_event_logs_type ON event_logs (event_type);
CREATE INDEX idx_event_logs_data ON event_logs USING GIN (event_data);

-- 様々なイベントを記録
INSERT INTO event_logs (event_type, event_data) VALUES
    ('user_login', '{"user_id": 123, "ip": "192.168.1.1", "device": "mobile"}'),
    ('purchase', '{"user_id": 123, "items": [{"id": 1, "qty": 2}], "total": 5000}'),
    ('error', '{"code": "E001", "message": "Connection timeout", "service": "payment"}');

-- 特定ユーザーのイベントを検索
SELECT event_type, event_data, created_at
FROM event_logs
WHERE event_data @> '{"user_id": 123}'
ORDER BY created_at DESC;

JSONBを使うべきケースと避けるべきケース

JSONBを使うべきケース

1. スキーマが動的に変化するデータ

1
2
3
4
5
6
-- ユーザーが自由に定義するカスタムフィールド
CREATE TABLE custom_entities (
    entity_id  SERIAL PRIMARY KEY,
    entity_type VARCHAR(50) NOT NULL,
    data        JSONB NOT NULL
);

2. 外部システムからのデータ保存

1
2
3
4
5
6
-- 外部APIのレスポンスをそのまま保存
CREATE TABLE external_data (
    source      VARCHAR(100) NOT NULL,
    raw_data    JSONB NOT NULL,
    fetched_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

3. 階層構造を持つデータ

1
2
3
4
5
6
-- 組織階層やカテゴリツリー
CREATE TABLE categories (
    category_id SERIAL PRIMARY KEY,
    name        VARCHAR(100) NOT NULL,
    metadata    JSONB DEFAULT '{}'
);

4. 疎なデータ(多くのNULLを含む可能性があるデータ)

1
2
3
4
5
6
-- 製品ごとに異なるオプション属性
-- 通常のカラムにすると多数のNULLが発生する場合に有効
CREATE TABLE product_options (
    product_id  INTEGER PRIMARY KEY,
    options     JSONB DEFAULT '{}'
);

JSONBを避けるべきケース

1. 頻繁にJOINするデータ

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
-- 避けるべき例: 外部キーをJSONB内に保存
-- ❌ 悪い例
CREATE TABLE orders_bad (
    order_id  SERIAL PRIMARY KEY,
    data      JSONB  -- {"user_id": 1, "items": [...]}
);

-- ✅ 良い例: リレーショナルな設計
CREATE TABLE orders_good (
    order_id  SERIAL PRIMARY KEY,
    user_id   INTEGER REFERENCES users(user_id),
    metadata  JSONB DEFAULT '{}'  -- 補助的なデータのみ
);

2. 集計が頻繁なデータ

1
2
3
4
5
6
7
8
-- 避けるべき例: 集計対象をJSONBに保存
-- ❌ 悪い例
SELECT SUM((data ->> 'amount')::NUMERIC)  -- 毎回キャストが必要
FROM transactions;

-- ✅ 良い例: 集計対象は通常カラム
SELECT SUM(amount)
FROM transactions;

3. 一意性制約が必要なデータ

1
2
3
4
5
6
7
8
9
-- ❌ JSONB内の値に一意性制約は設定できない
-- email がJSONB内にある場合、重複チェックが困難

-- ✅ 良い例: 一意性が必要なフィールドは通常カラムに
CREATE TABLE accounts (
    account_id  SERIAL PRIMARY KEY,
    email       VARCHAR(255) UNIQUE NOT NULL,
    profile     JSONB DEFAULT '{}'
);

4. 構造が固定されているデータ

1
2
3
4
5
6
7
8
9
-- 住所のように構造が固定されているデータは通常カラムが適切
-- ✅ 良い例
CREATE TABLE addresses (
    address_id   SERIAL PRIMARY KEY,
    postal_code  VARCHAR(10),
    prefecture   VARCHAR(20),
    city         VARCHAR(50),
    street       VARCHAR(100)
);

設計判断フローチャート

flowchart TD
    A[新しいデータ項目を追加] --> B{構造は固定?}
    B -->|はい| C{JOINや集計が多い?}
    B -->|いいえ/不明| D[JSONB型を検討]
    C -->|はい| E[通常カラムを使用]
    C -->|いいえ| F{一意性制約が必要?}
    F -->|はい| E
    F -->|いいえ| D
    D --> G{パフォーマンス要件}
    G -->|高速検索必須| H[GINインデックスを作成]
    G -->|検索少ない| I[インデックスなしで運用]

まとめ

PostgreSQLのJSONB型は、リレーショナルデータベースの堅牢性を維持しながら、スキーマレスなデータを柔軟に扱える強力な機能です。

本記事で学んだ主要ポイントを振り返ります。

  • JSON型とJSONB型の違い: ほとんどのケースでJSONB型を選択する。バイナリ形式で保存され、高速な処理とインデックスをサポート
  • データ操作: ->->>演算子で値を取得し、jsonb_set関数や||演算子で更新する
  • JSONB専用演算子: @>(包含)、?(存在確認)を使って効率的に検索する
  • GINインデックス: 大量データの検索にはGINインデックスが不可欠。用途に応じてjsonb_path_opsや式インデックスを使い分ける
  • 設計判断: 動的スキーマや疎なデータにはJSONBが有効だが、JOINや集計が多いデータには通常カラムを使用する

JSONBを適切に活用することで、アプリケーションの要件変化に柔軟に対応しながら、PostgreSQLの持つトランザクション整合性やSQLの表現力を最大限に活かすことができます。

参考リンク