Amazon Aurora DSQLがJSONデータ型に対応したので、TEXT退避していたカラムの移行を検討してみた
こんにちは。サービス開発室の武田です。
社内のシステムをAurora MySQLからAurora DSQLへ移行したことがあります。移行先のDSQLはPostgreSQL互換なのでjsonb列で持ちたかったのですが、当時のDSQLはjson/jsonbのどちらも保存用カラムとして使えませんでした。やむなくTEXT型に退避し、JSONの扱いはアプリケーション層に寄せる、という回避策を採りました。
そんな折、2026年5月4日に Aurora DSQLがPostgreSQLのJSONデータ型(圧縮付き)に対応 しました。私が見落としていたアップデートだったのですが、まさに過去の自分が困っていた点そのものです。公式アナウンスはこちらです。
今回はこのアップデートを、次の4点を中心に見ていきます。
- アップデートそのものの内容
- どんな不便が解消されるのか
- 設計に変化は出てくるのか
- 既存の
TEXT型カラムをどう移行するか
先に結論をまとめておきます。
- 保存できるのは
json型のみ。jsonbはクエリ実行時のランタイム型としてだけサポートされ、保存用カラムには使えない - 圧縮はデフォルトで有効。1 MiBの上限は「圧縮後」のサイズに効くので、
TEXT(1 MiB上限)より大きなペイロードが入る(3MBのJSONも入った) - PostgreSQLのJSON関数・演算子(9.16節)はJSON / JSONBともに同一挙動でフルサポート
- ただし
json列にはインデックスを張れない。しかも式インデックスもサポートされないので、「jsonb + GIN」も「(details->>'key')への関数インデックス」も両方使えない - 既存
TEXT列の移行は一筋縄ではいかない。ALTER COLUMN ... TYPEやDROP COLUMNが使えないので、列追加か、テーブルごと作り直して移し替えることになる
検証環境
今回触ったソフトウェアのバージョンです。
- Aurora DSQL: 東京リージョン(ap-northeast-1)のシングルリージョンクラスター。PostgreSQL 16互換(
SELECT version();で確認) - SQLクライアント:
psql(libpq 16)。IAM認証トークンで接続 - O/Rマッパ検証: Hibernate 7.2 + hypersistence-utils 3.15.2 + PostgreSQL 16(Docker)
json/jsonbの可否やエラーメッセージ、EXPLAINはDSQL実機で確認しています。Hibernateのddl-auto=validateとjson列の読み書きも、aurora-dsql-jdbc-connectorを介してDSQL実機で見ました。次に出てくるDDL生成の比較だけは、DSQLがjsonbをそもそも生成できないため、ローカルのPostgreSQL 16で取得しました。
json型で作って入れてみる
これまでDSQLは、半構造化データの格納に使えるjson・jsonbのいずれも保存用の型としてはサポートしていませんでした。JSONを扱いたければTEXT型に文字列として押し込むしかありません。クエリ内で::json / ::jsonbにキャストすればSQL側でも処理はできましたが、毎回キャストが必要なうえ、型としての検証も効かない状態でした。
今回のアップデートで、PostgreSQLのjsonデータ型が保存用カラムとして使えるようになりました。さっそく作って入れてみます。
CREATE TABLE audit_logs (
id VARCHAR(26) PRIMARY KEY,
action VARCHAR(50) NOT NULL,
details json
);
INSERT INTO audit_logs (id, action, details)
VALUES ('01J0000000000000000000000A', 'UPDATE_TICKET',
'{"ticketId":"T-123","changed":["status","assignee"]}');
CREATE TABLE
INSERT 0 1
問題なく作成・挿入できました。json型ですので、壊れたJSON文字列はINSERTの時点で弾かれます。
INSERT INTO audit_logs (id, action, details)
VALUES ('01J0000000000000000000000B', 'BROKEN', '{not valid json}');
ERROR: invalid input syntax for type json
LINE 2: VALUES ('01J0000000000000000000000B', 'BROKEN', '{not valid ...
^
DETAIL: Token "not" is invalid.
CONTEXT: JSON data, line 1: {not...
TEXT時代は壊れたJSONも書き込めてしまっていましたが、json型なら書き込んだ時点で弾けます。地味ですが助かります。
json と jsonb の扱いに注意
ここが一番引っかかりやすいポイントです。DSQLのドキュメントは保存用とランタイムを明確に分けて書いています。保存用の型はjsonのみで、jsonbは保存用カラム型としては使えません。jsonbはクエリ実行時の内部表現としてサポートされていて、json列をjsonbにキャストすればjsonb系の関数・演算子も使える、という建て付けです。
実際、jsonbでカラムを作ろうとすると拒否されます。
CREATE TABLE jb_test (id VARCHAR(26) PRIMARY KEY, data jsonb);
ERROR: datatype jsonb not supported
LINE 1: CREATE TABLE jb_test (id VARCHAR(26) PRIMARY KEY, data jsonb...
^
一方で、保存はjsonのままクエリ内でjsonbにキャストすれば、@>(包含)やjsonb_path_queryといったjsonbの機能が使えます。
-- json列を jsonb にキャストして包含演算子を使う
SELECT id FROM audit_logs WHERE details::jsonb @> '{"ticketId":"T-123"}';
-- jsonb_path_query もキャストすれば使える
SELECT jsonb_path_query(details::jsonb, '$.changed[*]') FROM audit_logs;
id
----------------------------
01J0000000000000000000000A
(1 row)
jsonb_path_query
------------------
"status"
"assignee"
(2 rows)
カラムの型とキャスト後の型を並べてみると、この「保存はjson・処理はjsonb」という関係がはっきりします。
SELECT pg_typeof(details) AS col_type, pg_typeof(details::jsonb) AS cast_type
FROM audit_logs LIMIT 1;
col_type | cast_type
----------+-----------
json | jsonb
(1 row)
実はPostgreSQLの公式ドキュメントも、特別な理由がなければjsonbを使うことを推奨しています(JSON Types)。jsonbはインデックスを張れて処理も速いです。PostgreSQLでは第一候補にするのが定石ですね。その感覚のままDSQLに持ち込むと、先ほどのとおりjsonbカラムは作れません。カラムはjson、必要に応じてクエリ内でjsonbにキャスト、が基本パターンです。
ひとつ注意点として、jsonからjsonbへのキャストではPostgreSQLの仕様どおり正規化がかかります。jsonbはキーの順序や空白を保持せず、重複キーは最後の値で畳み込まれます。保存はjsonで元テキストのまま保ち、検索や包含判定のときだけjsonbに変換する、と考えておくとよいです。
圧縮はデフォルトON、1 MiB制限は「圧縮後」
json列はデフォルトで圧縮が効きます。ここで効いてくるのが、DSQLの可変長型に共通する1 MiB制限が「圧縮後のサイズ」に対して適用される点です。これは体感した方が早いので、約3MBの(圧縮の効きやすい)JSONを入れてみます。
-- デフォルト(圧縮あり)の列と、STORAGE PLAIN(圧縮なし)の列を用意
CREATE TABLE comp_default (id VARCHAR(26) PRIMARY KEY, payload json);
CREATE TABLE comp_plain (id VARCHAR(26) PRIMARY KEY, payload json STORAGE PLAIN);
-- 約3MBのJSONを圧縮ありの列へ
INSERT INTO comp_default (id, payload)
VALUES ('...D1', ('{"blob":"' || repeat('a',3000000) || '"}')::json);
SELECT id, length(payload::text) AS raw_chars FROM comp_default;
INSERT 0 1
id | raw_chars
-------+-----------
...D1 | 3000011
(1 row)
生サイズ300万文字超のJSONが、圧縮ありの列には問題なく格納できました。同じものをSTORAGE PLAIN(圧縮無効)の列に入れると、今度は弾かれます。
INSERT INTO comp_plain (id, payload)
VALUES ('...D2', ('{"blob":"' || repeat('a',3000000) || '"}')::json);
ERROR: datatype limit greater than 1048576 bytes not supported for json
1048576バイトがちょうど1 MiBです。圧縮を切ると生サイズが、圧縮が効いていれば圧縮後サイズが、それぞれ1 MiBと比較される挙動ですね。圧縮を切りたいときのSTORAGEモードはこうです。
MAIN/EXTENDED/DEFAULTは圧縮を許可(jsonのデフォルトはこちら)PLAIN/EXTERNALは圧縮を無効化
DSQLは現時点でTOASTテーブルを持たないため、EXTERNALは「圧縮無効化」として機能します。モードの一覧はドキュメント記載のもので、今回触ったのはデフォルトとPLAINです。
関数・演算子は9.16節をフルサポート
PostgreSQLの9.16 JSON Functions and OperatorsはJSON / JSONBともに同一挙動でフルサポートされています。唯一の例外がjson_populate_record / json_populate_recordset(およびjsonb_版)です。DSQLがCREATE TYPEをサポートしていないため、カスタム複合型では使えません。テーブル行型・ビュー行型が相手であれば動きます。
SELECT * FROM json_populate_record(null::audit_logs,
'{"id":"X","action":"Y","details":{"a":1}}');
id | action | details
----+--------+---------
X | Y | {"a":1}
(1 row)
どんな不便が解消されるのか
移行時にTEXTへ退避したことで、いくつか不便だった点があります。今回のアップデートで解消されるものを整理してみます。
TEXTにJSONを押し込むハックがいらなくなる
移行時、私たちはHibernateのエンティティをこう書いていました。
@Type(JsonType::class)
@Column(columnDefinition = "TEXT") // ← 本当は json にしたかった
var details: Map<String, Any?> = emptyMap()
hypersistence-utilsのJsonTypeを使いながら、格納先はTEXTという構成です。JSONを文字列として押し込んでいるわけですね。json型が使えるようになったので、columnDefinitionを素直にjsonにできます。
サーバーサイドでJSONを操作できる
TEXT格納だと、JSON関数・演算子を使うたびにdetails::jsonのようなキャストが必要でした。json型なら、キャストなしでそのままSQLの中で使えます。
SELECT id, details ->> 'ticketId' AS ticket_id
FROM audit_logs
WHERE details ->> 'ticketId' = 'T-123';
id | ticket_id
----------------------------+-----------
01J0000000000000000000000A | T-123
(1 row)
ただし後述のとおりインデックスは張れないので、これは「書けるようになる」話であって「速くなる」話ではありません。
圧縮で大きく・安く格納できる
さっき見たとおり、TEXT(生1 MiB上限)に対してjsonは圧縮後1 MiB制限です。より大きなペイロードが入りますし、ストレージコストも抑えられます。APIレスポンスのスナップショットやイベントログのように、そこそこ大きくて圧縮の効きやすいデータでは恩恵が大きいです。
壊れたJSONはいつ弾かれるか
json型がINSERTの時点で構文チェックするのは、すでに見たとおりです。ではTEXT型のときはどうだったかというと、壊れたJSONを書き込んでもエラーになりません。素のSELECTで取り出してもそのまま返ってきます。JSONとして解釈しようとしたとき、はじめてエラーになります。TEXT列に壊れたJSONを入れて、取り出し方を変えてみます。
CREATE TABLE legacy (id VARCHAR(26) PRIMARY KEY, details text);
INSERT INTO legacy VALUES ('01J...', '{not valid json}'); -- 通ってしまう
SELECT details FROM legacy; -- 生TEXTなら読める
SELECT details::json FROM legacy; -- キャストすると初めてエラー
INSERT 0 1
details
------------------
{not valid json}
(1 row)
ERROR: invalid input syntax for type json
DETAIL: Token "not" is invalid.
O/Rマッパ経由でも同じです。hypersistenceのJsonTypeでTEXT列をMapとして読み込むと、壊れた行のところでJacksonがデシリアライズに失敗します。
HibernateException ... root: com.fasterxml.jackson.databind.exc.MismatchedInputException:
Cannot construct instance of `java.util.LinkedHashMap` ... from String value ('{not valid json}')
つまりTEXT格納では、壊れたデータが書き込みをすり抜け、ずっと後の読み出し時に、しかもバグの発生源から離れた場所でエラーになります。json型に変えると、この失敗がINSERTの時点まで前倒しされます。個人的にはこれが一番うれしい変化です。
設計に変化は出てくるか
「TEXTをjsonに置き換えれば終わり」ではありません。DSQLのjson型には、設計判断に効いてくる制約があります。
インデックスが張れない、しかも式インデックスも使えない
DSQLのデータ型一覧で、jsonのIndex supportはNoと明記されています。張ろうとすると弾かれます。
CREATE INDEX ASYNC idx_details ON audit_logs (details);
ERROR: datatype json is not supported in a key
PostgreSQLなら「ならば(details ->> 'key')への式インデックスで」と回避するところですが、DSQLは式インデックス自体をサポートしていません。
CREATE INDEX ASYNC idx_details_action ON audit_logs ((details ->> 'action'));
ERROR: expressions as index keys not supported
つまり「jsonb + GIN」も「関数インデックス」も両方とも塞がれているわけです。検索条件・JOIN・ソートに使う属性は、jsonの中に埋めず実カラムに出すのが基本になります(実カラムならインデックスを張れます。2026年1月のアップデートでNUMERICもインデックス対象になりました)。json列は「まとめて読む半構造化データ置き場」と割り切る方針ですね。
JSONフィールドを生成列にすればインデックスは張れる
「実カラムに出す」といっても、jsonとは別に同じ値をアプリケーション側で二重に書くのは面倒です。そこで使えるのが STORED生成列 です。DSQLのCREATE TABLEはGENERATED ALWAYS AS (...) STOREDをサポートしています。JSONの特定フィールドを生成列として取り出せば、その生成列にインデックスを張れます。
CREATE TABLE gen_cmp (
id VARCHAR(26) PRIMARY KEY,
details json,
gen_key VARCHAR(50) GENERATED ALWAYS AS (details ->> 'k') STORED
);
CREATE INDEX ASYNC idx_gen ON gen_cmp (gen_key);
-- 十分な行数を入れて ANALYZE したあと
EXPLAIN SELECT id FROM gen_cmp WHERE gen_key = 'K-4242';
Index Scan using idx_gen on gen_cmp (cost=100.28..208.29 rows=1 width=8)
Index Cond: ((gen_key)::text = 'K-4242'::text)
-> Storage Scan on idx_gen (...)
-> B-Tree Scan on idx_gen (...)
Index Cond: ((gen_key)::text = 'K-4242'::text)
生成列gen_keyはdetails ->> 'k'から自動で導出されるので、二重管理はいりません。先ほどのjson直接フィルターと違い、今度はIndex CondとしてIndex Scan using idx_genが使われています。json本体はまとめ読み用の置き場にしつつ、ホットなフィールドだけ生成列+インデックスで引く、という設計が取れるわけです。なお行数が少ないうちは、コスト判断でフルスキャンが選ばれることもあります。
スキャンコストが課金に直結する
インデックスが張れないということは、json内部の値で絞り込むクエリはスキャン+フィルターになります。実行計画を見てみます。
EXPLAIN SELECT id FROM audit_logs WHERE details ->> 'ticketId' = 'T-123';
QUERY PLAN
-----------------------------------------------------------------------------------------------------
Index Only Scan using audit_logs_pkey on audit_logs (cost=125100.05..176100.05 rows=5000 width=70)
Filter: ((details ->> 'ticketId'::text) = 'T-123'::text)
-> Storage Scan on audit_logs_pkey (cost=125100.05..176100.05 rows=1000000 width=70 loops=1)
Projections: id, details
-> B-Tree Scan on audit_logs_pkey (cost=125100.05..176100.05 rows=1000000 width=70 loops=1)
ポイントは、JSON条件がIndex CondではなくFilterとして後がけで評価されている点です。jsonフィールドそのものは絞り込みに使えていません(この検証環境では100万行規模のフルスキャン見積もりになりました)。DSQLはDPU(分散処理ユニット)ベースの課金ですので、読み取り量がそのままコストに乗ります。「SQLで書けるから」と安易にJSON内部条件で絞ると、性能とコストの両面に効いてきます。ここは注意したいところです。
圧縮はデフォルトのままでよいか
基本はデフォルト(圧縮ON)で問題ないはずです。ただし次のようなケースではSTORAGE PLAIN / EXTERNALを検討する余地があります。
- ペイロードが極端に小さい
- すでに圧縮済みのデータを入れる
- 格納サイズを予測可能にしたい
1 MiB(圧縮後)上限は設計上のガードとして残る
緩和されたとはいえ上限は上限です。肥大化しうるデータは別管理(S3など)にする判断は引き続き必要ですね。
既存のTEXT型カラムをどう移行するか
ここが今回の山場です。DSQLでは、既存のTEXT列を素直にjson化できません。
まず、できないことを確認する
PostgreSQLなら次のように一発です。
-- PostgreSQLならこれでいける
ALTER TABLE legacy_logs ALTER COLUMN details TYPE json USING details::json;
ところがDSQLでは弾かれます。
ERROR: unsupported ALTER TABLE ALTER COLUMN ... SET DATA TYPE statement
「では新しい列を作って、旧列を消そう」と思っても、DROP COLUMNもサポートされていません。
ALTER TABLE legacy_logs DROP COLUMN action;
ERROR: unsupported ALTER TABLE DROP COLUMN statement
さらにDSQLには、トランザクション周りの制約もあります。1トランザクションに置けるDDLは1つまで。そしてDDLとDMLを同じトランザクションに混在させることもできません。試しにDDLとDMLを1つのトランザクションに入れてみます。
BEGIN;
CREATE TABLE mix_test (id VARCHAR(26) PRIMARY KEY, details json);
INSERT INTO mix_test VALUES ('01J...', '{"a":1}');
COMMIT;
ERROR: ddl and dml are not supported in the same transaction
DETAIL: ddl count: 1, dml count: 1
これらの制約から、移行は「型を変える」のではなく「新しい列/テーブルを用意して移し替える」アプローチになります。現実的な戦略は2つです。
戦略A: 列追加+デュアルライト
まず新しいjson列を追加します。
ALTER TABLE legacy_logs ADD COLUMN details_json json;
次に既存データをバックフィルします。これはDDLとは別のトランザクションで実行します。ここで注意したいのが、DSQLの書き込みトランザクションには1回あたり3,000行・10 MiBの上限がある点です。行数の多いテーブルでは、主キー範囲などで区切って3,000行以下ずつUPDATEを分割する必要があります。
UPDATE legacy_logs SET details_json = details::json WHERE details_json IS NULL;
SELECT id, details_json ->> 'ticketId' AS ticket FROM legacy_logs;
あとはアプリケーションを新カラム参照に切り替えます。移行期間中は両方へ書くデュアルライトにしておくと安全ですね。ここまでの実行結果はこうです。
ALTER TABLE
UPDATE 1
id | ticket
----------------------------+--------
01J0000000000000000000000C | T-200
(1 row)
問題はDROP COLUMNできないことで、旧details(TEXT)列は残置されます。デッドカラムが残り続けるので、SELECT *で無駄に読まれないようカラムを明示するといった運用の手当てがいります。列をきれいに消したい場合は戦略Bですね。
戦略B: テーブルスワップ
json列をもつ新テーブルを作り、キャストしながら移し替え、RENAMEで入れ替えて旧テーブルをDROP TABLEします。
CREATE TABLE legacy_logs_new (
id VARCHAR(26) PRIMARY KEY,
action VARCHAR(50) NOT NULL,
details json
);
INSERT INTO legacy_logs_new (id, action, details)
SELECT id, action, details::json FROM legacy_logs;
-- 必要ならここで CREATE INDEX ASYNC
ALTER TABLE legacy_logs RENAME TO legacy_logs_old;
ALTER TABLE legacy_logs_new RENAME TO legacy_logs;
DROP TABLE legacy_logs_old;
入れ替え後、列の型はjsonになっています。
SELECT pg_typeof(details) FROM legacy_logs LIMIT 1;
pg_typeof
-----------
json
(1 row)
このSQLブロックは1つのトランザクションにまとめられません。DSQLでは1トランザクションに置けるDDLが1つだけで、DDLとDMLの混在もできないためです。CREATE / INSERT ... SELECT / RENAME×2 / DROPは、それぞれ独立したトランザクションとして流すことになります。INSERT ... SELECTも3,000行・10 MiBの書き込み上限に従うため、大きいテーブルでは主キー範囲などで分割します。
さらに見落としやすいのが、INSERT ... SELECTからRENAMEまでの間に旧テーブルへ入った書き込みが、新テーブルに反映されない点です。無停止でやるなら、次のような手当てが必要です。
- 書き込みを停止するメンテナンスウィンドウを設ける
- デュアルライトで差分を追従させ、件数やチェックサムで突き合わせる
データ量が大きいテーブルほど、戦略Bは慎重にいきたいですね。
Hibernate / Flyway側の対応
スキーマをFlyway(+aurora-dsql-flyway-support)で管理している場合、マイグレーションSQLは手書きで、上記の戦略をそのまま書けます。ただしDSQLは「1トランザクション1 DDL・DDL/DML混在不可」のため、Flywayがマイグレーションを1トランザクションで囲むデフォルトのままだと弾かれます。各マイグレーションに<バージョン>.sql.confを添え、executeInTransaction=falseでトランザクション境界を外しておきます。
# 例: V2026022601__schema.sql.conf
executeInTransaction=false
こうしておけば、1つのファイルに複数のDDLを書いても各文がオートコミットで流れます。エンティティ側はcolumnDefinitionをjsonにするだけです。
@Type(JsonType::class)
@Column(columnDefinition = "json") // TEXT → json
var details: Map<String, Any?> = emptyMap()
この変更が本当に効くのか確かめます。エンティティからDDLを生成させた結果がこちらです。
| アノテーション | 生成される列 |
|---|---|
@Type(JsonType::class) + columnDefinition = "json" |
json |
@Type(JsonType::class) + columnDefinition = "TEXT" |
text |
@Type(JsonType::class)(columnDefinitionなし) |
DDL生成エラー(SqlTypesの型コード1111 OTHER) |
@JdbcTypeCode(SqlTypes.JSON)(columnDefinitionなし) |
jsonb |
columnDefinition = "json"にしたエンティティでは、Mapを書き込んで読み戻す往復も問題なく動きます。ddl-auto = validateもjson列をそのまま受理しました。
注意したいのは最後の行です。hypersistenceを使わず純粋に@JdbcTypeCode(SqlTypes.JSON)だけで書くと、PostgreSQLDialectはjsonb列を生成します。ところがDSQLはjsonbの保存をサポートしていません。CREATE TABLE ... (data jsonb)はdatatype jsonb not supportedで弾かれます(DSQL実機で確認済みです)。hbm2ddlで自動生成させていると、ここでコケる可能性が高いわけです。columnDefinition = "json"を明示するか、Flywayで手書き管理するのが確実ですね。
それと、TEXT時代の壊れたJSON文字列が混入していると、details::jsonへのキャストでエラーになります。さっき見たinvalid input syntax for type jsonですね。移行前にバリデーションを挟んでおくと安全です。
まとめ
DSQLのjson型対応で、移行時に泣く泣くTEXT退避していたカラムを、ようやく本来の姿へ戻せるようになりました。今回の検証でわかったことを整理しておきます。
- 保存は
jsonのみ、jsonbはランタイム型、インデックス不可(式インデックスも不可)という点は、PostgreSQLの感覚のままだとハマる - 圧縮デフォルトON&圧縮後1 MiB制限で、
TEXTより大きく・安く格納できる(3MBのJSONも入った) - 既存
TEXT列の移行はALTER COLUMN TYPEやDROP COLUMNが使えないので、列追加+デュアルライト(戦略A)か、テーブルスワップ(戦略B)で移し替える json列は直接も式でもインデックスを張れないが、JSONフィールドをSTORED生成列に取り出せばインデックスを張れる(生成列ならIndex Scanになる)- 既存
TEXT列の移行では、3,000行・10 MiBのトランザクション上限や、テーブルスワップ中のライブ書き込み欠落にも気を配る
移行のときに苦労した課題が、アップデートでひとつ解消されたのを見られるのは、個人的にはうれしいですね。DSQLは制約の多いサービスとして始まりました。NUMERICインデックス(2026年1月)、IDENTITY/SEQUENCE(2026年2月)、そして今回のJSON型と、着実に「普通のPostgreSQL」に近付いていますね。
どなたかの参考になりましたら幸いです。







