
PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか
当サイトは Amazon.co.jp を宣伝しリンクすることで紹介料を得る手段を提供する、Amazonアソシエイト・プログラムの参加者です。価格・在庫はリンク先の最新情報をご確認ください。
PostgreSQL のテーブルは 8kB のページ単位でデータを保存していて、1行はページをまたげません。それなのに、text 型の列に 100kB の文章を入れても、jsonb 型の列に大きな JSON を入れても、問題なく保存できます。これを支えているのが TOAST(The Oversized-Attribute Storage Technique)という仕組みです。
TOAST は普段は意識しなくても勝手に働いてくれますが、仕組みを知らないと「テーブルは小さいのにディスクが減らない」「列を1つ足しただけのクエリが急に遅い」といった現象の理由が分かりません。この記事では、PostgreSQL の公式ドキュメントに沿って仕組みを整理し、手元の PostgreSQL 18.6 で実際に確かめた結果を載せていきます。
NOTE
実行結果は、Homebrew の PostgreSQL 18.6(macOS / arm64)で initdb した新しいクラスタで確認したものです。ページサイズなどは既定値のままです。
TOAST が働く条件
ドキュメントによると、TOAST は保存しようとする行全体の大きさが TOAST_TUPLE_THRESHOLD(通常は約2kB)を超えたときに働きます。働き始めると、行が TOAST_TUPLE_TARGET(これも通常は約2kB)より小さくなるまで、列の値を次の2つの方法で小さくしていきます。
- 値を圧縮する
- 値を行の外(out-of-line)に出し、TOAST テーブルという別のテーブルに保存する
行の外に出した値は、元の行には 18 バイトの「TOAST ポインター」だけが残ります。ポインターの大きさは、元の値がどれだけ大きくても 18 バイトで変わりません。
TOAST が使えるのは text、bytea、jsonb、varchar のような可変長(varlena)の型だけです。integer や timestamp のような固定長の型は対象外です。また、TOAST は値の長さを表すヘッダーの2ビットを使うため、1つの値の上限は 1GB(2の30乗 - 1 バイト)になります。
実際に確かめる
大きさと中身の違う4つの値を入れてみます。
CREATE TABLE docs (id int PRIMARY KEY, title text, body text);
INSERT INTO docs VALUES
(1, 'small', repeat('a', 100)),
(2, 'repeat 3KB', repeat('abc', 1000)),
(3, 'random 3KB', (SELECT string_agg(md5(i::text || random()::text), '') FROM generate_series(1, 94) i)),
(4, 'random 100KB', (SELECT string_agg(md5(i::text || random()::text), '') FROM generate_series(1, 3125) i));
SELECT id, title,
octet_length(body) AS raw_bytes,
pg_column_size(body) AS stored_bytes,
pg_column_compression(body) AS compression,
pg_column_toast_chunk_id(body) IS NOT NULL AS out_of_line
FROM docs ORDER BY id;実行結果:
id | title | raw_bytes | stored_bytes | compression | out_of_line
----+--------------+-----------+--------------+-------------+-------------
1 | small | 100 | 101 | | f
2 | repeat 3KB | 3000 | 46 | pglz | f
3 | random 3KB | 3008 | 3008 | | t
4 | random 100KB | 100000 | 100000 | | t使った関数は次のとおりです。
pg_column_size: 保存に使われているバイト数。圧縮されていれば圧縮後の大きさpg_column_compression: 圧縮の方式。圧縮されていなければ NULLpg_column_toast_chunk_id: TOAST テーブルに置かれた値の ID。行の中にあれば NULL(PostgreSQL 17 で追加)
結果を読むと、TOAST の動きがそのまま見えます。
- id 1 は100バイトしかないので、TOAST は働かず行の中に保存されています
- id 2 は 3,000 バイトありますが、同じ文字の繰り返しなので 46 バイトに圧縮され、圧縮だけで行の中に収まりました
- id 3 と 4 は md5 のハッシュ値をつないだ文字列で、繰り返しが無いので圧縮が効かず、そのまま TOAST テーブルへ移されています
id 3 が圧縮されていないのは、PostgreSQL の既定の圧縮方式 pglz が、圧縮しても十分に小さくならない値は圧縮せずに保存するためです。
TOAST テーブルの中身
TOAST できる列を持つテーブルには、専用の TOAST テーブルが1つずつ作られます。場所は pg_class.reltoastrelid で分かります。
SELECT reltoastrelid::regclass FROM pg_class WHERE relname = 'docs';
-- pg_toast.pg_toast_16384TOAST テーブルは chunk_id、chunk_seq、chunk_data の3列を持ち、行の外に出した値をチャンクに分けて保存します。ドキュメントによると、チャンクの大きさ TOAST_MAX_CHUNK_SIZE は「1ページにチャンクの行が4つ収まるように」決められていて、約2,000バイトです。中を見てみます。
SELECT chunk_id, count(*) AS chunks,
max(octet_length(chunk_data)) AS max_chunk_bytes,
sum(octet_length(chunk_data)) AS total_bytes
FROM pg_toast.pg_toast_16384
GROUP BY chunk_id ORDER BY chunk_id;実行結果:
chunk_id | chunks | max_chunk_bytes | total_bytes
----------+--------+-----------------+-------------
16392 | 2 | 1996 | 3008
16393 | 51 | 1996 | 100000手元の環境ではチャンクの大きさは 1,996 バイトでした。3,008 バイトの値は2つのチャンクに、100,000 バイトの値は51個のチャンクに分かれています。TOAST テーブルには (chunk_id, chunk_seq) の一意なインデックスがあり、値を読むときはこのインデックスを使ってチャンクを集め、つなぎ直します。
4つの保存方式
列ごとに、TOAST をどう適用するかの方式(storage)が決まっています。
| 方式 | 圧縮 | 行の外へ出す | 主な用途 |
|---|---|---|---|
PLAIN | しない | しない | integer など TOAST できない型(これしか選べない) |
EXTENDED | する | する | TOAST できる型のほとんどの既定値。まず圧縮し、それでも大きければ外へ出す |
EXTERNAL | しない | する | 圧縮せずに外へ出す。大きな text や bytea の部分取り出しが速くなる |
MAIN | する | 原則しない | 圧縮して行の中に置く。他に方法が無いときだけ最後の手段として外へ出す |
今の設定は pg_attribute.attstorage で確かめられます(p = PLAIN、x = EXTENDED、e = EXTERNAL、m = MAIN)。
attname | attstorage
---------+------------
id | p
title | x
body | x変えるときは ALTER TABLE ... ALTER COLUMN ... SET STORAGE を使います。
ALTER TABLE docs ALTER COLUMN body SET STORAGE EXTERNAL;ドキュメントの注意として、SET STORAGE はテーブルの中身をその場で書き換えません。今後の INSERT や UPDATE で使う方式を決めるだけです。
EXTERNAL が向いているのは、大きな値の一部だけを読む使い方です。圧縮されていない値なら、substr(body, 1, 100) のような部分の取り出しで、必要なチャンクだけを読めます。圧縮されていると、全体を展開してから切り出すことになります。
圧縮方式: pglz と lz4
圧縮には pglz(PostgreSQL 独自の方式)と lz4 の2つがあります。lz4 は PostgreSQL 14 で追加され、PostgreSQL を --with-lz4 付きでビルドしている必要があります。どちらを使うかは、列の COMPRESSION オプションか、パラメーター default_toast_compression で決まります。
SHOW default_toast_compression; -- pglz(PostgreSQL 18 の既定値)
CREATE TABLE docs_lz4 (id int PRIMARY KEY, title text, body text COMPRESSION lz4);先ほどの id 2(repeat('abc', 1000))を lz4 の列に入れると、pglz で 46 バイトだったものが 32 バイトになりました。
ここで1つ落とし穴があります。INSERT INTO docs_lz4 SELECT * FROM docs のように別のテーブルからコピーすると、id 2 は pglz のまま 46 バイトで入りました。ドキュメントにも、INSERT ... SELECT では元のテーブルの値が展開されるとは限らず、圧縮済みの値は元の方式のまま残ることがあると書かれています。ALTER TABLE ... SET COMPRESSION も既存のデータは書き換えません。圧縮方式をそろえたいなら、値を作り直す(pg_dump と pg_restore で入れ直すなど)必要があります。
速度の傾向については、pgEdge の Shaun Thomas 氏が 2026年10月30日の記事で、1万文字の値を10万行入れる試験を公開しています。INSERT は pglz の約52秒に対して lz4 が約1.8秒と大きな差が出た一方、全件の length() を合計する読み込みでは lz4 の方が2割ほど遅いという結果でした。1人の筆者の環境での結果ですが、書き込みが多いなら lz4 が有利と考えてよさそうです。なお、PostgreSQL 19 のリリースノートでは default_toast_compression の既定値が lz4 に変わることになっています(19 は2026年11月6日時点で RC 1 の段階です。詳しくは「PostgreSQL 19 Beta の新機能」)。
テーブルの大きさの見方
TOAST を知らないと、テーブルの大きさを見誤ります。PostgreSQL には大きさを測る関数がいくつかあり、TOAST を含むかどうかが違います。
| 関数 | 含むもの |
|---|---|
pg_relation_size(t) | テーブル本体(ヒープ)だけ |
pg_table_size(t) | 本体 + TOAST テーブル + 空き領域マップなど。インデックスは含まない |
pg_total_relation_size(t) | pg_table_size + すべてのインデックス |
1行あたり約8,000バイトの本文を持つ記事を1万件入れたテーブルで測ると、次のようになりました。
SELECT pg_size_pretty(pg_relation_size('articles')) AS heap,
pg_size_pretty(pg_table_size('articles') - pg_relation_size('articles')) AS toast_etc,
pg_size_pretty(pg_total_relation_size('articles')) AS total; heap | toast_etc | total
--------+-----------+-------
664 kB | 80 MB | 81 MB本体は 664kB しかなく、データのほとんどは TOAST テーブルにあります。pg_relation_size だけを見て「小さなテーブル」と判断すると、実際のディスク使用量と100倍以上ずれることになります。監視やディスクの見積もりには pg_total_relation_size を使います。
性能への影響
TOAST は、大きな値を使わないクエリを速くしてくれます。本体のテーブルが小さく保たれるので、大きな列に触れないクエリは本体だけを読めば済むからです。逆に、大きな列を読むと TOAST テーブルへの読み込みが発生します。この差を EXPLAIN (ANALYZE, BUFFERS) で見てみます。
| クエリ | 読んだバッファ |
|---|---|
SELECT count(title) FROM articles | 83 |
SELECT sum(octet_length(body)) FROM articles | 83 |
SELECT sum(length(body)) FROM articles | 40,083 |
SELECT count(*) FROM articles WHERE body LIKE '%ffff%' | 40,083 |
title だけを読むクエリは、本体の 83 ページを読むだけで終わります。length(body) と LIKE は、1万件の値すべてを TOAST テーブルから集めてくるので、バッファの読み込みが約480倍に増えました。
面白いのは octet_length(body) です。バイト数は TOAST ポインターに記録されている元の大きさから分かるので、TOAST テーブルを読まずに済んでいます。一方 length() は文字数を数えるため、値を全部読んで展開する必要があります。似た関数でもコストが大きく違うことがある、という例です。
SELECT * の隠れたコスト
もう1つ、見落としやすい点があります。EXPLAIN ANALYZE は、結果をクライアントに返すための変換をしないので、出力する列の TOAST の読み込みは計測に入りません。
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM articles;
Seq Scan on articles (actual time=0.027..0.633 rows=10000.00 loops=1)
Buffers: shared hit=97 read=68
Execution Time: 0.834 msこれだけ見ると 1 ミリ秒もかからない軽いクエリですが、PostgreSQL 17 で追加された SERIALIZE オプションを付けると、結果を送る形に変換するところまで計測されます。
EXPLAIN (ANALYZE, BUFFERS, SERIALIZE) SELECT * FROM articles;
Seq Scan on articles (actual time=0.003..0.435 rows=10000.00 loops=1)
Buffers: shared hit=165
Serialization: time=48.600 ms output=78407kB format=text
Buffers: shared hit=34357 read=5666 written=2384
Execution Time: 49.448 ms約78MB を送るために4万バッファを読み、実行時間は約50倍になりました。SELECT id, title に変えると、Serialization は 0.349 ミリ秒、233kB です。一覧画面のように本文が要らないところで SELECT * を使っていると、このコストを毎回払うことになります。ORM でモデルを丸ごと取ってくる書き方は、まさにこれに当たります。
UPDATE で TOAST はどう変わるか
ドキュメントには、UPDATE では変更しない列の値はそのまま引き継がれるので、行の外にある値を変えない UPDATE には TOAST のコストがかからない、と書かれています。確かめてみます。
SELECT pg_column_toast_chunk_id(body) FROM articles WHERE id = 1; -- 16420
UPDATE articles SET title = 'renamed' WHERE id = 1;
SELECT pg_column_toast_chunk_id(body) FROM articles WHERE id = 1; -- 16420(同じ)
UPDATE articles SET body = body || 'x' WHERE id = 1;
SELECT pg_column_toast_chunk_id(body) FROM articles WHERE id = 1; -- 26420(新しい ID)title だけの更新では、本文は同じチャンク ID のままでした。本文を1文字でも変えると、値全体が新しいチャンクとして書き直されます。差分だけを書き換える仕組みはありません。
その結果、大きな値を頻繁に更新すると TOAST テーブルが膨らみます。全件の本文に1文字足す UPDATE を実行すると、次のようになりました。
更新前: 80 MB
全件 UPDATE 後: 160 MB
VACUUM 後: 160 MBPostgreSQL の UPDATE は古い行を残して新しい行を書く(MVCC)ので、古いチャンクも VACUUM で回収されるまで残ります。そして VACUUM は回収した領域を再利用できるようにするだけで、通常はファイルを小さくしません。ファイルを縮めるには VACUUM FULL などでテーブルを書き直す必要があり、その間テーブルはロックされます(PostgreSQL 19 では、領域を回収する REPACK コマンドが追加される予定です)。
カウンターや最終アクセス日時のように頻繁に変わる値と、大きな本文や JSON を同じ行に置いている場合でも、前者だけを UPDATE するなら本文のチャンクは書き直されません。ただし、本文の一部を何度も書き換えるような使い方(大きな jsonb の1キーだけを jsonb_set で更新し続けるなど)では、毎回値全体が書き直されます。そうした列は別のテーブルに分けるか、変わる部分を独立した列にする設計を検討します。
まとめ
- TOAST は、行が約2kBを超えたときに、大きな列を圧縮するか TOAST テーブルへ移す仕組み。移した値は約2,000バイトのチャンクに分かれ、行には18バイトのポインターが残る
- 列ごとに
PLAIN/EXTENDED/EXTERNAL/MAINの方式があり、既定はほとんどEXTENDED - 圧縮は pglz と lz4。
INSERT ... SELECTやSET COMPRESSIONでは既存の値の方式は変わらない - テーブルの大きさは
pg_total_relation_sizeで見る。pg_relation_sizeは TOAST を含まない - 大きな列を読まないクエリは速い。
SELECT *やlength()は TOAST を読みに行き、EXPLAIN ANALYZEでは出力列の読み込みが見えないのでSERIALIZEを使う - 大きな値を UPDATE すると値全体が書き直され、TOAST テーブルが膨らむ。VACUUM では通常ファイルは縮まない
大きな値は TOAST に任せておけば保存できますが、読み方と更新の仕方によってはそのコストが表に出てきます。一覧で SELECT * を使っていないか、大きな列を頻繁に書き換えていないかは、TOAST を知っていると見直せるポイントです。インデックスの効き方については「データベースのインデックスの基本」、実行計画の読み方は「実行計画と結合アルゴリズム」で解説しています。


