はじめに
最近、JSONデータ型を知った。「カラムを増やさなくていい。何でも入れられる」。
ただ、調べていくと「正規化できないからアンチパターン」という意見が多い。
本当にそうなのか。メリットとデメリットを整理してみた。
1. DBにJSONを詰め込んだ
まず、JSONデータ型とは何かを簡単に説明する。
データベースは普通、テーブルの各カラムに「このカラムには数字だけ」「この列には文字列だけ」という型を決めて使う。JSONデータ型は、その制約を外して「{ "name": "田中", "age": 30 }」のような自由な形式のデータをそのまま入れられるカラムだ。MySQL 5.7以降やPostgreSQLで使える。
-- ユーザー情報を全部 properties という1つのカラムに詰め込む
CREATE TABLE users (
id serial primary key,
properties jsonb not null -- ここに何でも入れる
);
名前も住所も電話番号も、全部 properties に放り込む。**カラム追加はもう要らない。もう何も怖くない。
本当に・・・?
2. DBはJSONの中身を守らない
「同じ電話番号を持つユーザーを一覧にしてほしい」。
普通のカラムなら、こう書けば終わる。
-- 普通のカラムなら1行
SELECT * FROM users WHERE phone = '090-1234-5678';
でも電話番号がJSONの中に埋まっていると、こうなる。
-- JSONの中を検索しようとすると途端に複雑になる
SELECT u1.id, u2.id
FROM users AS u1, users AS u2
WHERE u1.properties->'電話番号' = u2.properties->'電話番号'
AND u1.id != u2.id;
さらに「複数の電話番号を持つユーザーを比較したい」となった瞬間、SQLはこうなった。
-- 複数電話番号を持つユーザー同士で、同じ番号を持つペアを探す
SELECT u1.id, u2.id, phone1->>'number' AS 共通番号
FROM users AS u1, users AS u2
CROSS JOIN LATERAL jsonb_array_elements(u1.properties->'電話番号') AS phone1
CROSS JOIN LATERAL jsonb_array_elements(u2.properties->'電話番号') AS phone2
WHERE phone1->>'number' = phone2->>'number'
AND u1.id < u2.id;
CROSS JOIN LATERAL、jsonb_array_elements、->>と->の違い。初めて見るキーワードが並ぶ。初見だと読めない。
それぞれ何をしているか、簡単に説明する。
-
jsonb_array_elements(...)— JSONの中の配列を「1行ずつバラす」関数。["090-xxxx", "080-yyyy"]という配列を、2行のレコードとして扱えるようにする。 -
CROSS JOIN LATERAL— 「各行ごとにサブクエリを実行して結果を横に並べる」結合。上のjsonb_array_elementsと組み合わせて、配列の中身を行として展開するのに使う。 -
->と->>の違い — どちらもJSONの中の値を取り出す演算子だが、->はJSON型のまま返し、->>は文字列として返す。->で取り出した"090-xxxx"と090-xxxxは型が違うため=で比較できない。->>で統一しないとバグる。
普通のSQLなら絶対に出てこないキーワードばかりだ。
なぜこうなるのか。普通のカラムなら、DBは「このカラムには何が入っているか」を最初から知っている。だから WHERE phone = '090-xxxx' と書けば最適化してくれる。でもJSONカラムの場合、DBは中身の構造を知らない。「このJSONの中に電話番号という配列があって、そのnumberフィールドを取り出して比較しろ」と、クエリのたびに全部説明しなければならない。
しかも、JSONの中のデータ型はDBが守ってくれない。普通のカラムなら「数字」の列に「文字列」を入れようとするとエラーが出る。普通のカラムなら型違いはDBが弾いてくれる。
-- 普通のINTカラムに文字列を入れるとエラーになる
INSERT INTO users (age) VALUES ('三十'); -- → ERROR: invalid input syntax for type integer
でもJSONカラムの中身は、何を入れても通る。
-- JSONの中身はDBが一切チェックしない
INSERT INTO users (properties) VALUES ('{"age": "三十"}'); -- → 成功してしまう
アプリ側でこのデータを読んで数値として扱おうとした瞬間、クラッシュする。
外部キー制約もCHECK制約もJSONの中身には届かない。データが壊れても、DBは気づかない。
3. DBとJSONが噛み合う、3つの条件
ただ、JSONデータ型を全否定するつもりはない。使いどころがある。
外部サービスのAPIレスポンスをそのまま保存したいとき。
X(旧Twitter)のAPIのように、仕様が予告なく変わる外部サービスと連携する場合。受け取ったデータをとりあえず丸ごと保存しておきたい、というケース。カラムを事前に決められないなら、JSONが合う。
設定値など、一度保存したらほぼ読むだけのとき。
数百件程度のデータで、検索もせず、丸ごと取得して使うだけ。この場合はパフォーマンスへの影響も小さい。
カテゴリによって必要な属性がバラバラなとき。
「本」にはISBNが必要だが、「服」にはサイズが必要。「PC」にはCPUのスペックが必要。これを普通のカラムで管理しようとすると、こうなる。
CREATE TABLE products (
id serial primary key,
name text not null,
isbn text, -- 本用。服やPCにはNULL
size text, -- 服用。本やPCにはNULL
cpu text, -- PC用。本や服にはNULL
memory_gb int -- PC用。本や服にはNULL
);
ほとんどの行でカラムの半分以上がNULL。カテゴリが増えるたびにALTER TABLEが走る。こういうケースではJSONが現実的な選択肢になる。
CREATE TABLE products (
id serial primary key,
name text not null,
category text not null, -- "book" / "clothes" / "pc"
attributes jsonb not null -- カテゴリごとの属性をここに入れる
);
共通点がある。どれも「どんなデータが来るか事前に決められない」か「検索しない」かのどちらかだ。
逆に、次の3つに当てはまるならJSONを使うべきじゃない。
| 条件 | なぜダメか |
|---|---|
| テーブルを分けて正規化できる | 普通のカラムで持てば、検索も並べ替えも自由にできる |
| 頻繁に値を更新したい | JSONは「一部だけ更新」が苦手で毎回全体を書き直す |
| その値で検索・絞り込みをしたい | データが増えると全件スキャンになり遅くなる |
JSONデータ型はデータベースの便利な機能を捨てる代わりに柔軟性を得る、最後の切り札だ。
4. DBが諦めた分、コードが守れ
それでもJSONを選んだとしよう。ならば、次の罠が待っている。
例を挙げてみる。
// フロントエンドから受け取ったJSONをそのままDBに保存
var data map[string]interface{} // 「何でも入れられる箱」
json.NewDecoder(r.Body).Decode(&data)
jsonBytes, _ := json.Marshal(data)
db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", jsonBytes)
map[string]interface{} は「キーも値も型もなんでもOK」という箱だ。フロントから来たものをそのまま受け取って、そのままDBに流している。
これの何が問題か。
DBは何も守ってくれない。 { "theme": "dark", "retry_count": "無限" } が送られてきても、そのままDBに保存される。数値のフィールドに文字列が入っても、誰も気づかない。
アプリも守っていない。 map で受けてスルーしているから、コードのどこにも「何が正しいデータか」が書かれていない。後でこのカラムを読もうとした人は「ここに何が入ってるんだ?」と首をかしげることになる。
壊れたデータはいつか必ずやってくる。 フロントエンドのバグ、APIの直叩き、テストデータの混入。「フロントでバリデーションしているから大丈夫」は危険だ。壊れたデータが入ったとき、アプリが突然落ちる。
DBがデータを守ってくれない分、アプリ側が全部引き受けなければならない。だから、構造体でスキーマを定義する。
// 「user_settingsのJSONには何が入るか」を明示する
type UserSetting struct {
Theme string `json:"theme" validate:"required,oneof=light dark system"`
ShowProfile bool `json:"show_profile"`
}
これで「themeにはlight/dark/systemのどれかが入る」「show_profileはtrue/false」だとわかる。コードが仕様書になる。
さらに、保存する前にバリデーション(検証)を通す。
func SaveUserSetting(db *sql.DB, rawJSON []byte) error {
var setting UserSetting
// ① 構造体に当てはめる(型が合わなければここで失敗)
if err := json.Unmarshal(rawJSON, &setting); err != nil {
return fmt.Errorf("データの形式が不正です: %w", err)
}
// ② 値の内容を検証する("無限"のような不正値を弾く)
if err := validate.Struct(setting); err != nil {
return fmt.Errorf("バリデーションエラー: %w", err)
}
// ③ 構造体から再度JSONに変換してDBへ保存
// (定義にないフィールドはここで自動的に除去される)
saveBytes, _ := json.Marshal(setting)
_, err := db.Exec("INSERT INTO user_settings (setting_json) VALUES (?)", saveBytes)
return err
}
一度構造体に変換してから再びJSONにすることで、「定義にない余計なフィールド」が混入するのも防げる。柔軟性はDBに任せる。規律はアプリが持つ。 それがJSONを使うときの正しい分担だ。
5. ALTER TABLEの呪いは、DBを変えても消えない
JSONを選んだ代償は、まだある。
「やっぱり theme じゃなくて color_mode という名前にしたい」。そう思ったとき、普通のカラムなら1行で終わる。
-- 普通のカラムなら1行
ALTER TABLE user_settings RENAME COLUMN theme TO color_mode;
でもJSONの中身は ALTER TABLE で変えられない。
-- JSONの場合、中身を直接リネームする手段がない
-- 古いデータには "theme"、新しいデータには "color_mode" が混在する
すでにDBに入っている古いデータには theme が入っている。新しいデータには color_mode が入っている。読み出すたびに「古いデータかどうか」を確認して処理を分岐させる必要が出てくる。
ALTER TABLEが怖くてJSONに逃げたのに、データの移行作業をコードで全部書くことになる。 具体的にはこうなる。
// 読み出すたびに、古いデータか新しいデータかを判定する
func GetColorMode(setting UserSetting) string {
if setting.Version == 1 {
return setting.Theme // v1は "theme" という名前だった
}
return setting.ColorMode // v2以降は "color_mode"
}
// → バージョンが増えるたびに、この分岐が伸び続ける
対策として、最初からバージョン番号を持たせておく方法がある。
type UserSetting struct {
Version int `json:"v"` // 1, 2, 3 ...と増やす
Theme string `json:"theme"` // v1の時代のフィールド
}
バージョンを見て処理を分岐させれば、古いデータも新しいデータも両方扱える。ただ、このロジックをずっと書き続けることになる。これがJSONを選んだときの本当のコストだ。
おわりに
JSONに逃げた。でも逃げた先には、複雑なクエリと、守られないデータと、コードに書かれない仕様が待っていた。
テーブルを分けて正規化できるなら、素直にカラムを切り出す。
それでもJSONを選ぶなら、覚悟を持って選ぶ。構造体を定義する。バリデーションを通す。データ移行の複雑さをアプリ側で引き受ける。DBが捨てた責任を、アプリが全部拾い上げる。
JSONに逃げるな。逃げるなら、規律まで捨てるな。
参考
-
曽根壮大『失敗から学ぶ RDBの正しい歩き方』第8章「JSONの甘い罠」
www.amazon.co.jp/dp/4297104083 -
wataruko「JSON型カラムを"アンチパターンにしない"ためのGo実装戦略」(Qiita, 2025-12-13) https://qiita.com/wataruko/items/600c5f146ffda6ca7155