TL;DRPostgreSQLの行は8KBのページに収まる必要があります。ヘッダーのオーバーヘッドを差し引くと、1行あたり正確に8160バイトしか使えません。可変長カラムをEXTENDEDストレージに切り替えれば、PostgreSQLが自動的に値を圧縮してTOASTテーブルに退避してくれます:
ALTER TABLE your_table ALTER COLUMN large_column SET STORAGE EXTENDED;
-- 新しいストラテジを既存の行に適用するため、行を書き直す
UPDATE your_table SET large_column = large_column WHERE id IS NOT NULL;
それでも制限に引っかかる場合は、テーブルを正規化するか、BLOBをデータベースの外に出す必要があります。
なぜこのエラーが発生するのかPostgreSQLのすべての行は8KBのページに格納されます。TOAST(The Oversized-Attribute Storage Technique)はその回避策です。単一カラムの値がおよそ2KBを超えると、PostgreSQLはその値を圧縮したり、別のTOASTテーブルに移動したりします。ただし、TOASTはカラム単位で発動するため、行単位では発動しません。
つまり、各カラムに300バイトのJSONを持つ30カラムのテーブルでは、個々のカラムに対してTOASTが発動することはありません。しかし30 × 300 = 9000バイトとなり、8160バイトの制限を大きく超えてしまいます。そのため、すべてのINSERTが次のエラーで失敗します:
ERROR: row is too big: size 8208, maximum size 8160
もう一つの落とし穴はPLAINストレージです。特定の固定幅配列型や、明示的にPLAINに設定されたカラムは、圧縮もアウトオブライン化もできません。そのバイト数は削減不可能です。
まず診断する何かを変更する前に、どのカラムがTOASTをブロックしているかを確認しましょう:
SELECT
a.attname AS column_name,
t.typname AS data_type,
CASE a.attstorage
WHEN 'p' THEN 'PLAIN (TOASTなし)'
WHEN 'm' THEN 'MAIN (インライン圧縮、最終手段としてTOAST)'
WHEN 'x' THEN 'EXTENDED (圧縮+アウトオブライン)'
WHEN 'e' THEN 'EXTERNAL (アウトオブライン、圧縮なし)'
END AS storage_strategy
FROM pg_attribute a
JOIN pg_type t ON a.atttypid = t.oid
WHERE a.attrelid = 'your_table'::regclass
AND a.attnum > 0
AND NOT a.attisdropped
ORDER BY a.attnum;
次に実際のサイズを計測します:
SELECT pg_size_pretty(AVG(pg_column_size(t.*))::bigint) AS avg_row_size
FROM your_table t;
-- 最も大きな原因を特定する
SELECT id, pg_column_size(t.*) AS row_bytes
FROM your_table t
ORDER BY row_bytes DESC
LIMIT 10;
MAINまたはPLAINストレージになっている可変長カラム(text、varchar、jsonb、bytea)が対処の起点です。
修正1 — カラムのストレージストラテジを変更する多くの場合、ALTER文一つで解決できます。MAINカラムをEXTENDEDに切り替えると、PostgreSQLはまず値を圧縮し、それでも大きすぎる場合はアウトオブラインに移動します:
-- text、varchar、jsonb、xmlカラムの場合
ALTER TABLE events ALTER COLUMN notes SET STORAGE EXTENDED;
ALTER TABLE events ALTER COLUMN payload SET STORAGE EXTENDED;
-- 圧縮オーバーヘッドなしでアウトオブラインにしたいbyteaの場合
ALTER TABLE events ALTER COLUMN raw_blob SET STORAGE EXTERNAL;
注意点として、新しいストラテジはALTER実行後に書き込まれた行にのみ適用されます。既存の行は明示的に書き直すまで古いディスク上のフォーマットを維持します:
-- 軽量な書き直し(各行を一時的にロック)
UPDATE events SET notes = notes;
-- またはゼロダウンタイムの書き直しにpg_repackを使用(pg_repack拡張が必要)
pg_repack -t events -d your_database
修正2 — 複数カラムをJSONBに統合するカラム数の多いテーブルが典型的な原因です。15〜30個のtextカラムがあり、その多くは特定の行でNULLになっています。任意のカラムを一つのJSONBカラムにまとめることで、行の幅とスキーマの複雑さの両方が削減できます。JSONBはデフォルトでEXTENDEDストレージのため、圧縮は自動的に行われます:
-- オーバーフローするフィールドを保持するJSONBカラムを追加
ALTER TABLE events ADD COLUMN metadata JSONB;
-- 既存の行をマイグレーション
UPDATE events
SET metadata = jsonb_build_object(
'extra_field_1', extra_field_1,
'extra_field_2', extra_field_2,
'extra_field_3', extra_field_3
);
-- 確認後、古いカラムを削除
ALTER TABLE events
DROP COLUMN extra_field_1,
DROP COLUMN extra_field_2,
DROP COLUMN extra_field_3;
個別のB-treeインデックスが不要で、単一クエリで一緒に読み出されることが多いフィールドに最適です。
修正3 — 1対1の子テーブルで正規化するスキーマが固定されている場合(ORM、外部制約、DDLを管理するレガシーアプリなど)は、テーブルの垂直分割が回避策です。サイズが大きく、アクセス頻度の低いカラムを子テーブルに移動し、必要な時にJOINして取得します:
CREATE TABLE events_content (
event_id BIGINT PRIMARY KEY REFERENCES events(id) ON DELETE CASCADE,
description TEXT,
raw_payload BYTEA,
audit_log TEXT
);
INSERT INTO events_content (event_id, description, raw_payload, audit_log)
SELECT id, description, raw_payload, audit_log FROM events;
ALTER TABLE events
DROP COLUMN description,
DROP COLUMN raw_payload,
DROP COLUMN audit_log;
-- 完全なレコードが必要な時はJOINする
SELECT e.*, ec.description, ec.raw_payload
FROM events e
LEFT JOIN events_content ec ON ec.event_id = e.id
WHERE e.id = 123;
修正4 — BLOBをデータベースの外に移動する画像、PDF、ZIPアーカイブなどのバイナリファイルはPostgreSQLの行に格納すべきではありません。500KBのPDF一つだけで8160バイトの制限を軽く超えてしまいます。ファイルはS3、CDN、またはローカルディスクに保存し、テーブルには参照情報だけを保持しましょう:
-- パス/URL参照カラムを追加
ALTER TABLE documents ADD COLUMN file_url TEXT;
-- アプリケーション層:ファイルをS3にアップロードし、URLを書き込む
UPDATE documents
SET file_url = 'https://your-bucket.s3.amazonaws.com/docs/doc_123.pdf'
WHERE id = 123;
-- マイグレーション完了後、インラインBLOBカラムを削除
ALTER TABLE documents DROP COLUMN file_blob;
動作確認失敗したINSERTを再試行する前に、以下のクエリで修正が正しく適用されたことを確認してください:
-- テーブル全体での最大行サイズ
SELECT MAX(pg_column_size(t.*)) AS max_row_bytes
FROM your_table t;
-- 8160未満であること
-- TOASTテーブルにアウトオブラインデータがあることを確認
SELECT
c.relname AS toast_table,
pg_size_pretty(pg_total_relation_size(c.oid)) AS toast_size
FROM pg_class c
JOIN pg_class parent ON parent.reltoastrelid = c.oid
WHERE parent.relname = 'your_table';
-- 最初に失敗したINSERTを再試行
INSERT INTO your_table (col1, col2, large_col) VALUES ('...', '...', '...');
-- ERROR: row is too big が出ずに成功するはず

