PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

PostgreSQL の TOAST 入門 - 大きな値はどこに、どう保存されるのか

作成日:
読了:約20分
更新日:

当サイトは 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つの方法で小さくしていきます。

  1. 値を圧縮する
  2. 値を行の外(out-of-line)に出し、TOAST テーブルという別のテーブルに保存する

行の外に出した値は、元の行には 18 バイトの「TOAST ポインター」だけが残ります。ポインターの大きさは、元の値がどれだけ大きくても 18 バイトで変わりません。

Loading diagram...

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: 圧縮の方式。圧縮されていなければ NULL
  • pg_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_16384

TOAST テーブルは 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 articles83
SELECT sum(octet_length(body)) FROM articles83
SELECT sum(length(body)) FROM articles40,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 MB

PostgreSQL の 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 を知っていると見直せるポイントです。インデックスの効き方については「データベースのインデックスの基本」、実行計画の読み方は「実行計画と結合アルゴリズム」で解説しています。

参考リンク

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

データベースインデックス入門 - B-treeの仕組みと、効くクエリ・効かないクエリ

約10分

データベースのインデックスを実務目線で整理します。フルスキャンとの違い、B-tree インデックスがなぜ速いのか、等価・範囲・前方一致・ORDER BY・JOIN で効く理由、複合インデックスの左端プレフィックス、カバリングインデックス(index-only scan)、列に関数を使うと効かない・前方ワイルドカード LIKE が効かないといった落とし穴、書き込みコストやストレージのトレードオフ、InnoDB のクラスタ化インデックスと PostgreSQL の違い、EXPLAIN の読み方まで、PostgreSQL・MySQL 公式と Use The Index, Luke を一次ソースにまとめます。

楽天市場のおすすめPR / 楽天アフィリエイト