Direct TOAST v2, faster, smaller and no migration needed
Hackorum builds and tests every patch posted to the lists, not only commitfest submissions. This is Hackorum's own CI rather than the PostgreSQL project's, and it is still under testing - please report anything that looks wrong.
You can run a PostgreSQL built from this patch straight from Docker, with no checkout and no build:
docker run --rm -p 5432:5432 ghcr.io/hackorum-dev/postgres-patch:t253689psql -h localhost -U postgresBuilt from patchset v1 (message #1), September 05, 2026 at 12:37 PM.
Every patchset is also pushed to a branch of our PostgreSQL fork, so you can check out the same tree CI built. Without a PostgreSQL checkout:
git clone --branch t253689_1 https://github.com/hackorum-dev/postgres.gitIn a checkout you already have, add the fork once:
git remote add hackorum https://github.com/hackorum-dev/postgres.gitthen, for this patchset and every later one:
git fetch hackorum t253689_1 && git checkout t253689_1Patchset v1 (message #1) is on t253689_1
Hi Michael, hackers,
Attached is a v2 patch series implementing "Direct TOAST", a new storage format
for out-of-line (TOASTed) variable-length attributes in PostgreSQL.
After a few rounds of reviewing it I finally feel that the code is
reasonably clean for others to take a look.
Michael Re: your concern in earlier discussion about just adding the
direct toast checks directly into code next to
VARATT_IS_EXTERNAL_ONDISK - this is doen this way because I consider
direct toast to be a simplified and streamlined subtype of traditional
toast which just cuts out the index lookup part. This is also
exemplified by zero-downtime / zero-migration switch to direct toast
(and back)
I hoped this will result in less code, but the newly introduced
b-tree-in-toast-table ended up adding enough code that this isn't the
case now
Direct TOAST addresses longstanding write amplification, index contention, and
read latency bottlenecks in the TOAST subsystem by replacing logical OID-based
B-Tree index lookups with direct physical tuple identifier (TID) addressing.
1. Motivation & Background
--------------------------
In traditional PostgreSQL (Plain TOAST):
- Every out-of-line datum allocates a unique 32-bit OID (va_valueid).
- Chunks are stored in an auxiliary relation (pg_toast_<relid>) and indexed
by a B-Tree index on (chunk_id, chunk_seq).
- To read an out-of-line datum, PostgreSQL opens a B-Tree index scan. For small
datums (<= 2KB), reading a single chunk requires navigating 2–3 index buffer
pages before reaching the chunk heap page.
- Writing large attributes causes massive index write amplification: storing a
100-chunk datum requires inserting 100 heap tuples PLUS 100 B-Tree index
tuples, generating corresponding WAL records for each index modification, and
incurring B-Tree page lock contention.
- Generating va_valueid relies on GetNewOidWithIndex() to prevent OID
collisions,
which becomes an operational bottleneck and carries a 2^32 OID
wraparound limit
per database.
Recent discussions on pgsql-hackers (e.g. the 8-Byte TOAST proposal, Commitfest
6747) have focused on widening chunk_id from 32-bit to 64-bit to prevent OID
wraparound. However, widening chunk_id still retains the B-Tree index lookup
model, index write amplification, and WAL churn, while requiring invasive
cluster-wide changes (modifying pg_control, pg_resetwal, and varsup.c).
Direct TOAST takes a different approach: rather than widening the index key,
it eliminates the index lookup entirely for out-of-line chunk access.
1.1 Main advantages
1.1.1 No migration needed - just set toast_flavour=direct and you can
continue with the same toast table
1.1.2 Faster - see next section
1.1.3 Less space used - for huge tables this can mean tebns or
hundreds of gigabytes saved
1.1.4 Faster vacuuming - as there is no TOAST index to vacuum the
vacuums are faster and more lightweight.
1.2 Some test results:
1.2.1 large 25M row table with 64 toasted fields
My tests show that Direct Toast is both faster and saves space.
I ran on a table with 64 toasted fields, first inserting 16 fields and
then updating one of the remaining 3 16-field sets each time.
The initial filling to 25,000,000 tuples was done separately for each
table and the Direct Toast (DT) table was consistently 2x faster.
Then I ran a parallel update test of tables with traditional and
direct toast for 100 hours, updating both tables the same number of
times, and the result was that
- direct toast grew to 380GB
- traditional to 500GB .
Then I ran separate 1 hour runs of updates on top of same tables first
for traditional toast then for direct toast .
The direct toast did 1385 TPS while traditional toast did 635 TPS
1.2.2 pgvector unindexed queries
For top N queries on vectors large enough to be toasted average query
times wete 5% to 30% better for Direct Toast
2. Direct TOAST Architecture
----------------------------
2.1. Physical Pointer Layout (varatt_direct)
We introduce a new on-disk toast pointer tag:
VARTAG_DIRECT = 19
The pointer struct varatt_direct stores:
int32 va_rawsize; /* Original uncompressed data size */
uint32 va_extinfo; /* External stored size + 2
compression bits */
Oid va_toastrelid; /* RelID of TOAST table */
ItemPointerData va_tid; /* Physical TID of root/terminal chunk */
Crucially:
sizeof(varatt_direct) == sizeof(varatt_external) == 18 bytes
Because both pointer formats have the exact same size, parent table tuples
experience zero change in layout, tuple headers, or alignment padding.
2.2. Three-Tier Storage Hierarchy
Depending on value size, Direct TOAST uses three storage tiers:
a) Single Chunk (datum size <= TOAST_MAX_CHUNK_SIZE, ~2KB):
The payload fits in one chunk. The parent tuple's va_tid points directly
to the leaf chunk.
Detoasting requires 0 index lookups and exactly 1 buffer page fetch
(bypassing
B-Tree root, internal, and leaf pages).
b) Flat Multi-Chunk (<= DIRECT_TOAST_TREE_THRESHOLD = 100 chunks, up to ~200KB):
Leaf data chunks are written first. A terminal root chunk contains the last
slice of data in chunk_data and an array of preceding child TIDs in
chunk_tids
(type tid[]). The parent tuple's va_tid points to this root chunk.
Detoasting fetches the root chunk, reads chunk_tids, and directly fetches
each leaf chunk by TID without consulting an index.
c) Hierarchical Tree / DAG (> DIRECT_TOAST_TREE_THRESHOLD = 100 chunks):
For large values, chunks form a multi-level balanced tree with a fanout of
DIRECT_TOAST_FANOUT (50). Intermediate chunks store:
- chunk_tids (tid[]): child chunk TIDs.
- chunk_tid_offsets (int8[]): cumulative byte offsets.
Partial slice fetching (detoast_attr_slice) uses binary search over
chunk_tid_offsets to prune non-overlapping subtrees, achieving O(log N)
buffer lookups without touching an index.
2.3. Zero-Index Writes & WAL Reduction
- Direct TOAST chunks are written with chunk_id = InvalidOid (0).
- Writing direct chunks completely skips index_insert().
- For a 100-chunk write, this eliminates 100 index tuple inserts, eliminates
B-Tree concurrency lock contention, and cuts TOAST WAL volume by ~40–50%.
- The TOAST table index is defined as a partial index:
UNIQUE INDEX ON pg_toast_xxx (chunk_id, chunk_seq)
WHERE chunk_id IS NOT NULL;
This allows existing Plain TOAST rows to be indexed normally while keeping
Direct TOAST chunks out of the index, ensuring REINDEX and VACUUM remain safe.
2.4. Lock-Free In-Place Schema Upgrades
Existing tables can be upgraded from Plain to Direct TOAST on the fly:
ALTER TABLE my_table SET (toast_flavour = 'direct');
or via pg_ensure_direct_toast(reloid).
This performs a metadata-only catalog update adding chunk_tids and
chunk_tid_offsets with fast-default NULLs. No data rewrite or exclusive table
lock is required. Plain and Direct TOAST datums can coexist within the same
table indefinitely.
3. Structure of the Patch Series
--------------------------------
Patch 1: Refactor detoasting pipeline to unify full and slice fetches
Consolidates toast_fetch_datum() and toast_fetch_datum_slice() in detoast.c.
Historically, both functions duplicated buffer allocation, compression header
decoding, and table lifecycle logic. toast_fetch_datum() becomes a
clean inline
wrapper around toast_fetch_datum_slice(attr, 0, -1).
Patch 2: Add Direct TOAST catalog, GUC, and reloptions infrastructure
Defines struct varatt_direct and VARTAG_DIRECT in varatt.h. Adds the
default_toast_flavour GUC ('plain' | 'direct', default 'plain') and the
table storage parameter toast_flavour. Extends create_toast_table() to add
chunk_tids (tid[]) and chunk_tid_offsets (int8[]) to TOAST relations and
creates the partial index predicate (WHERE chunk_id IS NOT NULL).
Patch 3: Implement Direct TOAST core storage reading and writing
Implements the core read, write, slice, and cascaded delete engines:
- Writing: toast_save_datum_direct() implementing single-chunk fast-path,
flat multi-chunk arrays, and hierarchical DAG trees (>100 chunks).
- Reading: detoast.c direct TID fetching, single-chunk heap_fetch
optimization,
and recursive tree traversal with slice boundary pruning.
- Deletion: toast_delete_datum_direct_recursive() cascading deletes by TID.
- Adds comprehensive regression test suite
(src/test/regress/sql/direct_toast.sql).
Patch 4: Support Direct TOAST in logical decoding, replication, and
online REPACK
Adds replication and decoding support:
- reorderbuffer.c keys toast reassembly on tuple TID when chunk_id is NULL,
reconstructing direct TOAST DAGs bottom-up from the WAL stream.
- Supports unchanged and changed direct TOAST attributes in decode.c, proto.c,
and pgoutput.c.
- Adds isolation test specs (repack_direct_toast.spec) using injection points.
Patch 5: Add amcheck verification for Direct TOAST tuples
Extends verify_heapam in contrib/amcheck to validate direct TOAST pointers,
checking that va_tid points to a valid block and offset, verifying
that chunk_id
is NULL, verifying offset monotonicity in tree chunks, and cross-checking byte
counts against va_extinfo.
Patch 6: Add documentation for Direct TOAST
Adds SGML documentation in doc/src/sgml/storage.sgml, config.sgml, and
ref/create_table.sgml detailing Direct TOAST architecture, GUCs, storage
options, and performance considerations.
Patch 7: Add pg_ensure_direct_toast for in-place legacy TOAST table upgrade
Introduces ensure_direct_toast() and
pg_ensure_direct_toast(regclass) to perform
instant, metadata-only catalog upgrades on existing 3-column TOAST tables.
Adds automatic upgrade invocation in ATExecSetRelOptions() when setting
toast_flavour = 'direct'.
Patch 8: Add backend TOAST architecture documentation and clean up
detoast access
- Adds src/backend/access/common/README.toast providing comprehensive backend
architectural documentation for the TOAST subsystem.
- Enriches in-source comments in varatt.h, toast_internals.c, and toasting.c.
- Refactors ToastExternalMetadata in detoast.c to use an anonymous union for
direct_tp and tp, enforcing format mutual exclusivity and reducing stack
footprint.
- Cleans up attribute retrieval in detoast.c.
4. Testing & Verification
-------------------------
The patch series passes:
- Core regression tests: `make -C src/test/regress check-tests
TESTS="direct_toast"`
- Isolation tests: `make -C src/test/modules/injection_points check`
- Integrity checks: `make -C contrib/amcheck check` (SQL and TAP suites)
- Both pg_upgrade --link and pg_dump / pg_restore test matrices.
Feedback, suggestions, and reviews are very welcome!
Regards,
Hannu Krosing
Attachments:
t253689_1v2-0001-Refactor-detoasting-pipeline-to-unify-full-and-sl.patchapplication/x-patch; name=v2-0001-Refactor-detoasting-pipeline-to-unify-full-and-sl.patchDownload+6-50
v2-0005-Add-amcheck-verification-for-Direct-TOAST-tuples.patchapplication/x-patch; name=v2-0005-Add-amcheck-verification-for-Direct-TOAST-tuples.patchDownload+197-7
v2-0004-Support-Direct-TOAST-in-logical-decoding-replicat.patchapplication/x-patch; name=v2-0004-Support-Direct-TOAST-in-logical-decoding-replicat.patchDownload+499-38
v2-0003-Implement-Direct-TOAST-core-storage-reading-and-w.patchapplication/x-patch; name=v2-0003-Implement-Direct-TOAST-core-storage-reading-and-w.patchDownload+1763-44
v2-0006-Add-documentation-for-Direct-TOAST.patchapplication/x-patch; name=v2-0006-Add-documentation-for-Direct-TOAST.patchDownload+92-15
v2-0002-Add-Direct-TOAST-catalog-GUC-and-reloptions-infra.patchapplication/x-patch; name=v2-0002-Add-Direct-TOAST-catalog-GUC-and-reloptions-infra.patchDownload+115-8
v2-0007-Add-pg_ensure_direct_toast-for-in-place-legacy-TO.patchapplication/x-patch; name=v2-0007-Add-pg_ensure_direct_toast-for-in-place-legacy-TO.patchDownload+468-1
v2-0008-Add-backend-TOAST-architecture-documentation-and-.patchapplication/x-patch; name=v2-0008-Add-backend-TOAST-architecture-documentation-and-.patchDownload+175-11
On Sat, Sep 05, 2026 at 02:24:50PM +0200, Hannu Krosing wrote:
Attached is a v2 patch series implementing "Direct TOAST", a new storage format
for out-of-line (TOASTed) variable-length attributes in PostgreSQL.
Thanks for splitting that into a new thread.
Michael Re: your concern in earlier discussion about just adding the
direct toast checks directly into code next to
VARATT_IS_EXTERNAL_ONDISK - this is doen this way because I consider
direct toast to be a simplified and streamlined subtype of traditional
toast which just cuts out the index lookup part. This is also
exemplified by zero-downtime / zero-migration switch to direct toast
(and back)
Noted. Now they are as well some arguments that come into mind that
don't make it sound as an acceptable design, because this proposal is
about tradeoffs, mostly. As far as I know, there is never a magical
solution when it comes to software, and restrictions of your patch set
are in place, reflecting these tradeoffs.
There's a bit of bloat in this message; quoting the most relevant
parts only to make that readable. There is a lot of AI bloat in your
text, perhaps reconsider this approach before posting to the lists...
But well..
This gist of the proposal can be summarized based on this, in simpler
words (not everything, but these are the most relevant pieces here):
- Add a new varatt_direct, that acts as a new type of external
pointer, replace va_valueid by a ItemPointerData.
- The va_tid points to a a chunk in the TOAST table, that includes an
array of tids. This array of tids redirects to each chunk.
- Instead of an index lookup combined to a heap lookup, we need to
retrieve two heap blocks, one to get the tids array, one for the chunk
itself.
- The array of tids is stored in the *last* chunk.
- Bypass the index handling, because the tids don't require that.
- Avoid the 4-byte OID value wraparound by design, as this switches to
a tid.
Then I ran separate 1 hour runs of updates on top of same tables first
for traditional toast then for direct toast .The direct toast did 1385 TPS while traditional toast did 635 TPS
So, in terms of benchmarks, this is a claim based on:
- a pgbench workload with many toasted atttributes.
- pgvector
- no mention of configuration, as far as I can see, or anything?
Putting this last point aside for a minute..
Claiming that this approach is simply "better" based on only a subset
of workloads is debatable, and how it could be better in some other
cases while not impacting the performance of the default 4-byte TOAST
OID? A few things that come on top of my mind:
- How does this fart with readahead? The tids array is in the last
block, if we have a cold cache and need to retrieve the last block
*before* looking at a block away from that, isn't that a penalty in
itself if data does not fit completely into OS cache or even
shared_buffers?
- Support for read of slices, where retrieving short cuts of the TOAST
data could pay the price due to the last chunk requirement. substr()
is a common thing for applications.
- Lock contention and concurrency. A btree page for a TOAST table in
cache is able to hold hundreds of references to various entries.
Claiming that this can be always outperformed is unclear, to say the
least, by switching to one array of tids for each value stored in
TOAST divided in chunks? I think that this puts more pressure on the
OS cache or PG shared buffers when dealing with many hot values.
Crucially:
sizeof(varatt_direct) == sizeof(varatt_external) == 18 bytes
This claim looks incorrect to me, I am quickly measuring:
sizeof(varatt_external) = 16
sizeof(varatt_direct) = 20
So, yeah, I'm also puzzled with this statement.
2.4. Lock-Free In-Place Schema Upgrades
Existing tables can be upgraded from Plain to Direct TOAST on the fly:
ALTER TABLE my_table SET (toast_flavour = 'direct');
or via pg_ensure_direct_toast(reloid).
This performs a metadata-only catalog update adding chunk_tids and
chunk_tid_offsets with fast-default NULLs. No data rewrite or exclusive table
lock is required. Plain and Direct TOAST datums can coexist within the same
table indefinitely.
In v2-0002 (with comment pieces added to v2-0008 much later, no idea
why but):
+ TupleDescInitEntry(tupdesc, (AttrNumber) 4,
+ "chunk_tids",
+ TIDARRAYOID,
+ -1, 0);
+ TupleDescInitEntry(tupdesc, (AttrNumber) 5,
+ "chunk_tid_offsets",
+ INT8ARRAYOID,
+ -1, 0);
This is ensured by adding two new concepts to TOAST tables, even in
the existing TOAST 4-byte case: two new attributes and a partial
index. The existing attribute layer is moot when using one (4-byte
value) or the other (direct). This is a waste, and unlikely free.
The addition of a new partial index does not help much in that. I
understand that you've written that this way to claim a cheap rewrite
when switching over by manipulating data later on on upgrades, but
that does not sound acceptable here.
Finally, and the biggest elephant in the room here by far.. VACUUM
FULL, CLUSTER and REPACK *have* to be forbidden, because on rewrite
each command rewrites the tids in the parent. That's a legal
defensive set of commands because it is possible to reclaim bloat from
TOAST relations directly, and I doubt that we'd *ever* want to drop
this property, especially based on the benchmark claim of upthread. A
worst thing to me is that this seems to entirely disable their use due
to this in v2-0004, cluster_rel() or cluster.c:
+ if (OldHeap->rd_rel->relkind == RELKIND_TOASTVALUE)
[..,]
+ if (OldHeap->rd_att->natts >= 4 &&
The two new attributes are added *unconditionally*.
Another thing that is really disturbing to me is that using tids
lowers the protection regarding TOAST lookups. A TOAST value acts a
second barrier of protection if we miss a chunk, and we have a long
history of bugs in this area (spoiler: we still had two recent
discussions about the same set of issues for very old problems, still
unresolved). Relying on only a get_toast_snapshot() and a bare TID
lookup neither verifies nor enforces that the chunk we have retrieved
is the correct one. For this argument, I was not completely sure how
to put it into words first, so I have asked Claude about a good
definition regarding this point, to be told that direct pointers carry
no "identity", and I'm finding the term adapted here, because a value
acts as an identity to ensure that we have the chunk we expect, based
on the data on heap side.
My main assumption regarding this patch would be, mapping with
previous remarks I got, to use a reloption to decide which type of
external pointer to use and have a one-one mapping with what's stored
in heap rather than make the TOAST table definitions more complicated
than they should be. So: don't try to solve the rewrite problem now
and discard it, give the option for new tables to choose this method
(for the reasons listed in the last two paragraphs, I guess no anyway,
but that's what I would recommend if following up).
Saying all that, and after screening the patch set, there are two
things that I find attractive out of the bat.
Number 1, 0001. Making toast_fetch_datum() an inline function that
calls toast_fetch_datum_slice() sounds like an okay thing to do. Just
removing comments for the sake of moving code is never nice, or just
move the definition of toast_fetch_datum() to be closer to _slice().
Some could also claim about the code lacking symmetry with
toast_decompress_datum() and toast_decompress_datum_slice(), as well,
so we may also group these together..
Number 2, this thing, hidden in v2-0003 (for some reason, but the
split of the patch set is super weird to me, so I'm not quite sure to
follow entirely why you've done things this way for a couple of
parts):
+typedef struct ToastExternalMetadata
+{
It sounds to me that we could do that kind of thing *before* thinking
about adding new types of external pointers, because it simplifies the
data fetch in quite a few code paths? You could just rework that based
on HEAD, with only OID values around.
--
Michael
On Mon, Sep 7, 2026 at 5:13 AM Michael Paquier <michael@paquier.xyz> wrote:
On Sat, Sep 05, 2026 at 02:24:50PM +0200, Hannu Krosing wrote:
Attached is a v2 patch series implementing "Direct TOAST", a new storage format
for out-of-line (TOASTed) variable-length attributes in PostgreSQL.Thanks for splitting that into a new thread.
Michael Re: your concern in earlier discussion about just adding the
direct toast checks directly into code next to
VARATT_IS_EXTERNAL_ONDISK - this is doen this way because I consider
direct toast to be a simplified and streamlined subtype of traditional
toast which just cuts out the index lookup part. This is also
exemplified by zero-downtime / zero-migration switch to direct toast
(and back)Noted. Now they are as well some arguments that come into mind that
don't make it sound as an acceptable design, because this proposal is
about tradeoffs, mostly. As far as I know, there is never a magical
solution when it comes to software, and restrictions of your patch set
are in place, reflecting these tradeoffs.There's a bit of bloat in this message; quoting the most relevant
parts only to make that readable. There is a lot of AI bloat in your
text, perhaps reconsider this approach before posting to the lists...
But well..
It is always hard for me to decide how much I have to explain things.
And I need to explain much less to you than to others as you have been
working on these parts much more :)
This gist of the proposal can be summarized based on this, in simpler
words (not everything, but these are the most relevant pieces here):
- Add a new varatt_direct, that acts as a new type of external
pointer, replace va_valueid by a ItemPointerData.
- The va_tid points to a a chunk in the TOAST table, that includes an
array of tids. This array of tids redirects to each chunk.
There is a shortcut for the relatively common case where there is just
one chunk, in which case the data is directly in the chunk pointed to.
- Instead of an index lookup combined to a heap lookup, we need to
retrieve two heap blocks, one to get the tids array, one for the chunk
itself.
This is almost always faster because we retrieve the ctid array
directly, not via index lookup, avoiding all the index lookup steps -
open index, pin pages, do scans, etc.
As you mention later, there could be more needed optimisations around
locking and read-ahead.
- The array of tids is stored in the *last* chunk.
- Bypass the index handling, because the tids don't require that.
- Avoid the 4-byte OID value wraparound by design, as this switches to
a tid.Then I ran separate 1 hour runs of updates on top of same tables first
for traditional toast then for direct toast .The direct toast did 1385 TPS while traditional toast did 635 TPS
So, in terms of benchmarks, this is a claim based on:
- a pgbench workload with many toasted atttributes.
Yes, I wanted to make the toasting overhead visible without having to
consume excessive amount of disk space.
I will next run the performance tests with an actual case I have
encountered where a table with four toasted JSON fields ran out of
toast oids at 1 billion rows, at size of a few tens of TB.
- pgvector
Yes, this was at the other end of the spectrum where everything was
already in shared buffers and the speedup was purely from avoiding all
the index manipulation.
- no mention of configuration, as far as I can see, or anything?
I will write a more detailed report on benchmarks.
Putting this last point aside for a minute..
Claiming that this approach is simply "better" based on only a subset
of workloads is debatable, and how it could be better in some other
cases while not impacting the performance of the default 4-byte TOAST
OID?
The default 4-byte TOAST is unchanged except for some IF-s havin the OR added
A few things that come on top of my mind:
- How does this fart with readahead? The tids array is in the last
block, if we have a cold cache and need to retrieve the last block
*before* looking at a block away from that, isn't that a penalty in
itself if data does not fit completely into OS cache or even
shared_buffers?
The current implementaton is that it is either the tid array *or* the
data , not both, so the tid array lookup is just a faster index
lookup.
- Support for read of slices, where retrieving short cuts of the TOAST
data could pay the price due to the last chunk requirement. substr()
is a common thing for applications.
Storing tid arrays directly aacts as a faster index lookup, else the
slice implementation stays the same.
- Lock contention and concurrency. A btree page for a TOAST table in
cache is able to hold hundreds of references to various entries.
Fair point.
The reasons why I think it s still faster are:
1. the case for tiny toasted values will go directly to the page.
Currently "tiny" is one 2k chunk , but could be expanded to full page
as a follow-up
2. the case with just a small number of chunks will likely have the
data and tid array(s) in the same page, or pages very cloes to each
other.
3. for huge toasted values the full tid array is constructed before
the actual data retrieval starts, and it is done in order of magnitude
less page accesses than getting the same data from a b-tree index
takes.
Claiming that this can be always outperformed is unclear, to say the
least, by switching to one array of tids for each value stored in
TOAST divided in chunks? I think that this puts more pressure on the
OS cache or PG shared buffers when dealing with many hot values.
Do you have a speecific scenario in mind I could tests ?
Crucially:
sizeof(varatt_direct) == sizeof(varatt_external) == 18 bytesThis claim looks incorrect to me, I am quickly measuring:
sizeof(varatt_external) = 16
sizeof(varatt_direct) = 20
So, yeah, I'm also puzzled with this statement.
Me too, must have been left in from some earlier edit :(
2.4. Lock-Free In-Place Schema Upgrades
Existing tables can be upgraded from Plain to Direct TOAST on the fly:
ALTER TABLE my_table SET (toast_flavour = 'direct');
or via pg_ensure_direct_toast(reloid).
This performs a metadata-only catalog update adding chunk_tids and
chunk_tid_offsets with fast-default NULLs. No data rewrite or exclusive table
lock is required. Plain and Direct TOAST datums can coexist within the same
table indefinitely.In v2-0002 (with comment pieces added to v2-0008 much later, no idea why but): + TupleDescInitEntry(tupdesc, (AttrNumber) 4, + "chunk_tids", + TIDARRAYOID, + -1, 0); + TupleDescInitEntry(tupdesc, (AttrNumber) 5, + "chunk_tid_offsets", + INT8ARRAYOID, + -1, 0);
This was to cover a case where toast_flavour was set via global GUC.
Allowing the GUC for this now seems like a design mistake and should
not be allowed, as this will require all these checks.
This is ensured by adding two new concepts to TOAST tables, even in
the existing TOAST 4-byte case: two new attributes and a partial
index. The existing attribute layer is moot when using one (4-byte
value) or the other (direct). This is a waste, and unlikely free.
The addition of a new partial index does not help much in that.
It is not a *new* partial index, but the current PK is replaced with
this. In case of online conversion, the current PK constraint is
converted into this in-place.
I understand that you've written that this way to claim a cheap rewrite
when switching over by manipulating data later on on upgrades, but
that does not sound acceptable here.
You do not need to manipulate data at all if you are ok with current
data staying accessed via the 4-byte OID and index.
Finally, and the biggest elephant in the room here by far.. VACUUM
FULL, CLUSTER and REPACK *have* to be forbidden, because on rewrite
each command rewrites the tids in the parent.
They are only forbidden directly on the toast table, they work fine
when run on main table and they also result in a clustered order
synchronized with main table, which is not the case when running on
toast table directly with the current design
That's a legal
defensive set of commands because it is possible to reclaim bloat from
TOAST relations directly, and I doubt that we'd *ever* want to drop
this property, especially based on the benchmark claim of upthread.
If you have a workload that updates toasted columns this results in
these being sprinkled all over the toast table, with random oids, so
even CLUSTER will not put them in the same order as main table.
This is why you want to run REPACK on the main table if you need to
recover space AND also care about performance.
In my tests autovacuum kept the direct toast table in shape more
efficiently, most likely because it could skip the expensive index
cleanup phase, so there was less bloat accumulating.
A worst thing to me is that this seems to entirely disable their use due to this in v2-0004, cluster_rel() or cluster.c: + if (OldHeap->rd_rel->relkind == RELKIND_TOASTVALUE) [..,] + if (OldHeap->rd_att->natts >= 4 &&The two new attributes are added *unconditionally*.
Yes, but they are not used if you keep using only 4-byte OIDs. In that
case they only appear in the catalog tables.
And even when they are looked up, it is an order of magnitude faster
than opening and pinning index the same toast lookup
Another thing that is really disturbing to me is that using tids
lowers the protection regarding TOAST lookups. A TOAST value acts a
second barrier of protection if we miss a chunk, and we have a long
history of bugs in this area (spoiler: we still had two recent
discussions about the same set of issues for very old problems, still
unresolved). Relying on only a get_toast_snapshot() and a bare TID
lookup neither verifies nor enforces that the chunk we have retrieved
is the correct one.
Are we really re-checking the OID in the chunk tuple in current implementation.
I don't think we re-check the OID in the chunk tuple for b-tree index lookups.
For this argument, I was not completely sure how
to put it into words first, so I have asked Claude about a good
definition regarding this point, to be told that direct pointers carry
no "identity", and I'm finding the term adapted here, because a value
acts as an identity to ensure that we have the chunk we expect, based
on the data on heap side.
My main assumption regarding this patch would be, mapping with
previous remarks I got, to use a reloption to decide which type of
external pointer to use and have a one-one mapping with what's stored
in heap rather than make the TOAST table definitions more complicated
than they should be.
The complexity exists for a good reason: it removes complexity from
the toast field lookup path.
I acknowledge that the complexity of index lookup is currently well
hidden within the single function call "get toast chunks using index,"
but it is nonetheless present.
And it does not affect you unless you actually use direct toast - for
old 4-byte toast none of it is used.
So: don't try to solve the rewrite problem now
and discard it,
In my professional work zero-downtime fixes are very high on the priority list.
Making TOAST cheaper in space usage and toast lookups faster would
allow much wider flexibility in toast usage.
give the option for new tables to choose this method
(for the reasons listed in the last two paragraphs, I guess no anyway,
but that's what I would recommend if following up).
The main reason you may want to REPACK *only* the toast table is that
it currently behaves badly, partly because of expensive toast index
cleanups.
If you want performance back, you want to REPACK the main table, which
fixes the random placement of toasted field problem.
Saying all that, and after screening the patch set, there are two
things that I find attractive out of the bat.Number 1, 0001. Making toast_fetch_datum() an inline function that
calls toast_fetch_datum_slice() sounds like an okay thing to do. Just
removing comments for the sake of moving code is never nice, or just
move the definition of toast_fetch_datum() to be closer to _slice().
Some could also claim about the code lacking symmetry with
toast_decompress_datum() and toast_decompress_datum_slice(), as well,
so we may also group these together..
Yes, I will take a look.
I did not look closely at any (de)compression code as I think we can
do much better there in the future once we can use extra fields in the
toast table instead of cramming everything into two bits in the toast
pointer.
Number 2, this thing, hidden in v2-0003 (for some reason, but the
split of the patch set is super weird to me, so I'm not quite sure to
follow entirely why you've done things this way for a couple of
parts):
+typedef struct ToastExternalMetadata
+{
It sounds to me that we could do that kind of thing *before* thinking
about adding new types of external pointers, because it simplifies the
data fetch in quite a few code paths? You could just rework that based
on HEAD, with only OID values around.
Agreed
I only noticed that refactoring opportunity after adding the direct
toast code, when some functions became too long for my taste. I did
not set out to refactor code; I just did it when the non-refactored
code became uncomfortable to work with.
## In conclusion:
I still think that these three goals
- zero-downtime upgrade
- less space used
- faster performance
are equally important and should be tackled together.
As for your performance concerns, can you point me to use cases where
you think the current oid4 can be faster?
It does not have to be very detailed, just a general workload description helps.
I will run some tests to see if extra checks for direct toast and
index predicate has a measurable effect in oid4 path.
----
Best Regards
Hannu
Hi Hannu!
Great you're continuing this work! I've started to review your patch set,
and have question about tests and performance: have you tested it against
large toasted values? When experimenting with direct toast before my
prototype
was much faster on small values but starting with relatively large (have to
recover
previous test results so cannot say exact size), about tens of Mbs,
performance
starts to degrade and on very large values it is much slower compared to
the original
mechanics.
--
Regards,
Nikita Malakhov
Postgres Professional
The Russian Postgres Company
https://postgrespro.ru/
On Mon, Sep 7, 2026 at 9:45 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
Hi Hannu!
Great you're continuing this work! I've started to review your patch set,
and have question about tests and performance: have you tested it against
large toasted values? When experimenting with direct toast before my prototype
was much faster on small values but starting with relatively large (have to recover
previous test results so cannot say exact size), about tens of Mbs, performance
starts to degrade and on very large values it is much slower compared to the original
mechanics.
Hi, here is a guick tests inserting 10 rows of 1MB, 10MB and 100MB text datums
Direct toast is consistently faster by 7 to 15% on writes
datum size | plain | direct | speed-up
-------------------------------------------
1 MB | 58 | 54 | 7.4%
10 MB | 568 | 495 | 14.7%
100 MB | 6821 | 6139 | 11.1%
1000 MB | 64363 | 58763 | 9.5%
On the read side they are the same when the toast index fits in
memory, varying less than a percent between runs
select id, length(data) from largedirecttoast where id between 21 and
30; -> 19 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 183 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 1920 ms
For 1GB rows the above select was 2x slower on plain, 47 s vs 23 sec on direct
Here is what I tested:
dtoast=# create table largedirecttoast(id serial primary key, data
text storage external) with (toast_flavour=direct);
CREATE TABLE
Time: 4.351 ms
dtoast=# create table largeplaintoast(id serial primary key, data text
storage external) with (toast_flavour=plain);
CREATE TABLE
Time: 4.269 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largeplaintoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 58.330 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largedirecttoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 54.280 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largeplaintoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 567.808 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largedirecttoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 494.823 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largeplaintoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6820.868 ms (00:06.821)
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largedirecttoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6138.979 ms (00:06.139)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largedirecttoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 58763.165 ms (00:58.763)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largeplaintoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 64363.909 ms (01:04.364)
....
dtoast=# select id, length(data) from largedirecttoast where id
between 31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)
Time: 22981.708 ms (00:22.982)
dtoast=# select id, length(data) from largeplaintoast where id between
31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)
Time: 47346.034 ms (00:47.346)
The slowness of 1GB datum test in previous email is likely just
because of more levels in toast inex, as it should fit well into
shared buffers
table_name │ table_size │ toast_table_size │ toast_index_size │
──────────────────────────┼──────────────┼──────────────────┼──────────────────┼
public.largeplaintoast │ 11527708672 │ 11399487488 │ 125042688 │
public.largedirecttoast │ 11495727104 │ 11492515840 │ 8192 │
Time: 8.935 ms
dtoast=# show shared_buffers;
shared_buffers
────────────────
8GB
(1 row)
Show quoted text
On Mon, Sep 7, 2026 at 10:47 AM Hannu Krosing <hannuk@google.com> wrote:
On Mon, Sep 7, 2026 at 9:45 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
Hi Hannu!
Great you're continuing this work! I've started to review your patch set,
and have question about tests and performance: have you tested it against
large toasted values? When experimenting with direct toast before my prototype
was much faster on small values but starting with relatively large (have to recover
previous test results so cannot say exact size), about tens of Mbs, performance
starts to degrade and on very large values it is much slower compared to the original
mechanics.Hi, here is a guick tests inserting 10 rows of 1MB, 10MB and 100MB text datums
Direct toast is consistently faster by 7 to 15% on writes
datum size | plain | direct | speed-up
-------------------------------------------
1 MB | 58 | 54 | 7.4%
10 MB | 568 | 495 | 14.7%
100 MB | 6821 | 6139 | 11.1%
1000 MB | 64363 | 58763 | 9.5%On the read side they are the same when the toast index fits in
memory, varying less than a percent between runsselect id, length(data) from largedirecttoast where id between 21 and
30; -> 19 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 183 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 1920 msFor 1GB rows the above select was 2x slower on plain, 47 s vs 23 sec on direct
Here is what I tested:
dtoast=# create table largedirecttoast(id serial primary key, data
text storage external) with (toast_flavour=direct);
CREATE TABLE
Time: 4.351 ms
dtoast=# create table largeplaintoast(id serial primary key, data text
storage external) with (toast_flavour=plain);
CREATE TABLE
Time: 4.269 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largeplaintoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 58.330 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largedirecttoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 54.280 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largeplaintoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 567.808 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largedirecttoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 494.823 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largeplaintoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6820.868 ms (00:06.821)
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largedirecttoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6138.979 ms (00:06.139)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largedirecttoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 58763.165 ms (00:58.763)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largeplaintoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 64363.909 ms (01:04.364)
....
dtoast=# select id, length(data) from largedirecttoast where id
between 31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)Time: 22981.708 ms (00:22.982)
dtoast=# select id, length(data) from largeplaintoast where id between
31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)Time: 47346.034 ms (00:47.346)
Also, truncate was faster on direct toast
dtoast=# truncate table largedirecttoast;
TRUNCATE TABLE
Time: 891.392 ms
dtoast=# truncate table largeplaintoast;
TRUNCATE TABLE
Time: 1193.535 ms (00:01.194)
Show quoted text
On Mon, Sep 7, 2026 at 10:54 AM Hannu Krosing <hannuk@google.com> wrote:
The slowness of 1GB datum test in previous email is likely just
because of more levels in toast inex, as it should fit well into
shared bufferstable_name │ table_size │ toast_table_size │ toast_index_size │
──────────────────────────┼──────────────┼──────────────────┼──────────────────┼
public.largeplaintoast │ 11527708672 │ 11399487488 │ 125042688 │
public.largedirecttoast │ 11495727104 │ 11492515840 │ 8192 │Time: 8.935 ms
dtoast=# show shared_buffers;
shared_buffers
────────────────
8GB
(1 row)On Mon, Sep 7, 2026 at 10:47 AM Hannu Krosing <hannuk@google.com> wrote:
On Mon, Sep 7, 2026 at 9:45 AM Nikita Malakhov <hukutoc@gmail.com> wrote:
Hi Hannu!
Great you're continuing this work! I've started to review your patch set,
and have question about tests and performance: have you tested it against
large toasted values? When experimenting with direct toast before my prototype
was much faster on small values but starting with relatively large (have to recover
previous test results so cannot say exact size), about tens of Mbs, performance
starts to degrade and on very large values it is much slower compared to the original
mechanics.Hi, here is a guick tests inserting 10 rows of 1MB, 10MB and 100MB text datums
Direct toast is consistently faster by 7 to 15% on writes
datum size | plain | direct | speed-up
-------------------------------------------
1 MB | 58 | 54 | 7.4%
10 MB | 568 | 495 | 14.7%
100 MB | 6821 | 6139 | 11.1%
1000 MB | 64363 | 58763 | 9.5%On the read side they are the same when the toast index fits in
memory, varying less than a percent between runsselect id, length(data) from largedirecttoast where id between 21 and
30; -> 19 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 183 ms
select id, length(data) from largedirecttoast where id between 21 and
30; -> 1920 msFor 1GB rows the above select was 2x slower on plain, 47 s vs 23 sec on direct
Here is what I tested:
dtoast=# create table largedirecttoast(id serial primary key, data
text storage external) with (toast_flavour=direct);
CREATE TABLE
Time: 4.351 ms
dtoast=# create table largeplaintoast(id serial primary key, data text
storage external) with (toast_flavour=plain);
CREATE TABLE
Time: 4.269 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largeplaintoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 58.330 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 100000) as datum1m)
insert into largedirecttoast(data) select large.datum1m from large,
generate_series(1,10);
INSERT 0 10
Time: 54.280 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largeplaintoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 567.808 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 1000000) as datum10m)
insert into largedirecttoast(data) select large.datum10m from large,
generate_series(1,10);
INSERT 0 10
Time: 494.823 ms
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largeplaintoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6820.868 ms (00:06.821)
dtoast=# with large as (SELECT repeat('abcdefghij', 10000000) as datum100m)
insert into largedirecttoast(data) select large.datum100m from large,
generate_series(1,10);
INSERT 0 10
Time: 6138.979 ms (00:06.139)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largedirecttoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 58763.165 ms (00:58.763)
dtoast=# with large as (SELECT repeat('abcdefghij', 100000000) as datum1000m)
insert into largeplaintoast(data) select large.datum1000m from large,
generate_series(1,10);
INSERT 0 10
Time: 64363.909 ms (01:04.364)
....
dtoast=# select id, length(data) from largedirecttoast where id
between 31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)Time: 22981.708 ms (00:22.982)
dtoast=# select id, length(data) from largeplaintoast where id between
31 and 40;
id │ length
────┼────────────
31 │ 1000000000
32 │ 1000000000
33 │ 1000000000
34 │ 1000000000
35 │ 1000000000
36 │ 1000000000
37 │ 1000000000
38 │ 1000000000
39 │ 1000000000
40 │ 1000000000
(10 rows)Time: 47346.034 ms (00:47.346)
On Mon, Sep 7, 2026 at 10:54 AM Hannu Krosing <hannuk@google.com> wrote:
The slowness of 1GB datum test in previous email is likely just
because of more levels in toast inex, as it should fit well into
shared bufferstable_name │ table_size │ toast_table_size │ toast_index_size │
──────────────────────────┼──────────────┼──────────────────┼──────────────────┼
public.largeplaintoast │ 11527708672 │ 11399487488 │ 125042688 │
public.largedirecttoast │ 11495727104 │ 11492515840 │ 8192 │Time: 8.935 ms
dtoast=# show shared_buffers;
shared_buffers
────────────────
8GB
(1 row)
Looks like this was caused by something else pushing data out of shared buffers.
Once thee index (and most data) was there, plain toast was as fast as
direct toast also on 1GB reads.