From b80c77f2c99c3a66c3aca352c3478b8b0086c198 Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 22:12:12 +0000 Subject: [PATCH 1/6] docs: GIN and BRIN cannot be chosen, not merely have not been seen (#1143) The limitations page left GIN as an open question and offered the fixture size as an explanation: "those settings are cost penalties rather than prohibitions". That is true in general and it is not what happens here. Both are bitmap-only access methods. A GIN index has no amgettuple at all, so the only path either can produce is a bitmap index scan feeding a bitmap heap scan, and the columnar table access method implements no bitmap-scan callback. The planner generates no such path, so cost never enters into it. MEASURED WITH ONE VARIABLE. Same 200,000 rows, same two indexes, same query, same settings, and the table access method as the only difference: heap GIN -> Bitmap Heap Scan + Bitmap Index Scan columnar GIN -> Seq Scan heap BRIN -> Bitmap Heap Scan + Bitmap Index Scan columnar BRIN -> Seq Scan same answers on both storages On PostgreSQL 18 the columnar plan is reported "Disabled: true" under enable_seqscan=off. The planner used a node it had been told not to use, because the pathlist held no alternative -- which is a stronger statement than the seq scan winning on cost, and one no row count can change. BRIN is settled by the same measurement; #1143 records it as never having been probed past the build. THE CODE COMMENT IS NOT DECORATION. The callback set differs by major -- 15 to 17 declare scan_bitmap_next_block and scan_bitmap_next_tuple, 18 removed the former in the read-stream rework -- so "implement the two callbacks" is wrong advice on two of the five majors. And table_scan_bitmap_* guards only against logical decoding before calling through the pointer, so a NULL member is a null function-pointer call in the executor rather than an ereport. A half-implementation crashes. Both read from each installed server own tableam.h by @jdatcmd and verified here against all five. The features.md link moves with the heading. The bullet index_am_support.sh reads out of that page is untouched, and the suite still passes 20 + 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 31 ++++++++++++++++++++++++++++++ docs/features.md | 2 +- docs/limitations.md | 43 +++++++++++++++++++++++++----------------- src/columnar_tableam.c | 17 +++++++++++++++++ 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 93841bcb..166b9093 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,37 @@ true until the next version shipped. ## [Unreleased] +### Changed + +- `docs/limitations.md` now says GIN and BRIN can never be chosen, rather than that + nothing has been seen to choose them (#1143). + + The old wording left GIN as an open question and suggested the fixture might have + been too small. It is not a cost problem. Both are bitmap-only access methods, and + the columnar table access method implements no bitmap-scan callback, so the planner + generates no path either index could serve. + + Measured on 200,000 rows, same data and same two indexes on both storages, with + `enable_seqscan`, `enable_indexscan` and `enable_indexonlyscan` off. The only + difference between the rows is the table access method: + + | storage | GIN plan | BRIN plan | + | --- | --- | --- | + | heap | Bitmap Heap Scan | Bitmap Heap Scan | + | columnar | Seq Scan | Seq Scan | + + On PostgreSQL 18 the columnar plan carries `Disabled: true`, which is the planner + reporting that it used a node it had been told not to use because no alternative + path existed. A row count cannot change that. BRIN is settled by the same + measurement, which #1143 records as never having been probed past the build. + + `src/columnar_tableam.c` gains a comment at the access-method routine, because the + callback set is not the same on every major and a half-implementation fails badly: + 15 to 17 declare `scan_bitmap_next_block` and `scan_bitmap_next_tuple`, 18 removed + the former, and `table_scan_bitmap_*` calls through the pointer after guarding only + against logical decoding, so a NULL member is a null function-pointer call rather + than an error. Reported by @jdatcmd. + ### Fixed - The session that deleted rows still scanned `delete_vector` sequentially, once per row diff --git a/docs/features.md b/docs/features.md index 2f422bb9..7b04ba84 100644 --- a/docs/features.md +++ b/docs/features.md @@ -120,7 +120,7 @@ coverage. - **The suite checks only the methods that bullet names.** An exclusion written anywhere else on this page is checked against nothing. So do not write one here. A method that does not work belongs in - [limitations](limitations.md#gin-and-brin-build-and-nothing-has-been-seen-to-use-them), + [limitations](limitations.md#gin-and-brin-build-and-no-plan-can-use-them), with its own evidence. - Every row is assigned a stable row number and synthetic item pointer at insert time, so ordinary index scans fetch rows by item pointer. diff --git a/docs/limitations.md b/docs/limitations.md index 39d83e42..7e969081 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -684,25 +684,34 @@ empty range contributes no bound in either direction. Both are recorded distinctly from "no summary at all". A table written before this existed keeps today's behaviour, rather than being pruned on a statistic nobody wrote. -### GIN and BRIN build, and nothing has been seen to use them +### GIN and BRIN build, and no plan can use them `CREATE INDEX` accepts `gin` and `brin` on a columnar table and the build -succeeds. That is all that is established. No plan has been observed choosing -either one. - -For GIN the question is open. A `jsonb` containment query on a 20,000-row table -still planned a sequential scan with four scan settings turned off. GIN supports -only bitmap scans, and those settings are cost penalties rather than -prohibitions. - -For BRIN the question is deeper. BRIN summarises ranges of physical blocks, and a -columnar table's block layout is not a heap's. Whether such a summary means -anything here is a design question, not a tuning one. It may be that the build -should be refused instead of accepted. - -[Issue #1143](https://github.com/commandprompt/pgcolumnar/issues/1143) tracks -both. Until it is settled, treat a successful `CREATE INDEX` with either method -as a build, not as a plan. +succeeds. Neither index can ever be chosen. + +Both are bitmap-only access methods. A GIN index has no `amgettuple` at all. So +the only path either one can produce is a bitmap index scan feeding a bitmap heap +scan. The columnar table access method implements no bitmap-scan callback. The +planner therefore generates no such path, and this is not a cost the row count can +change. + +Measured on 200,000 rows, with the same data and the same two indexes on both +storages, and with `enable_seqscan`, `enable_indexscan` and `enable_indexonlyscan` +turned off: + +| storage | GIN plan | BRIN plan | +| --- | --- | --- | +| heap | Bitmap Heap Scan | Bitmap Heap Scan | +| columnar | Seq Scan | Seq Scan | + +The only difference between the two rows is the table access method. Both return +the same rows. + +On PostgreSQL 18 the columnar plan is reported with `Disabled: true`. The planner +used a node it had been told not to use, because the alternative did not exist. + +Queries still answer correctly, through a sequential scan or the custom scan. A +`gin` or `brin` index on a columnar table is simply never read. **Use a GiST or an SP-GiST index for a selective overlap or containment query.** Both build on a columnar table and both answer the query. A columnar index scan diff --git a/src/columnar_tableam.c b/src/columnar_tableam.c index 16b81877..7366aeb6 100644 --- a/src/columnar_tableam.c +++ b/src/columnar_tableam.c @@ -2297,6 +2297,23 @@ static const TableAmRoutine pgcolumnar_am_methods = { .relation_estimate_size = pgcolumnar_relation_estimate_size, + /* + * NO BITMAP-SCAN CALLBACK IS SET, AND THE PLANNER IS WHAT MAKES THAT SAFE. + * GIN and BRIN are bitmap-only, so with no callback here no bitmap path is + * generated and neither index can be chosen (#1143). Measured: with + * enable_seqscan off on 200,000 rows the plan is a Seq Scan reported + * "Disabled: true", while the same data on heap gives a Bitmap Heap Scan. + * + * ANYONE IMPLEMENTING THIS MUST SET EVERY CALLBACK THE MAJOR DEFINES, and + * the set is not the same on all of them: 15 to 17 declare + * scan_bitmap_next_block and scan_bitmap_next_tuple, 18 removed the former + * in the read-stream rework and declares only the latter. + * + * A HALF-IMPLEMENTATION CRASHES RATHER THAN ERRORS. table_scan_bitmap_* + * guards only against logical decoding and then calls through the pointer, + * so a NULL member is a null function-pointer call in the executor. + * Reported by @jdatcmd. + */ .scan_sample_next_block = pgcolumnar_scan_sample_next_block, .scan_sample_next_tuple = pgcolumnar_scan_sample_next_tuple, }; From 09eb2bc0341326067f011055d742f2b8f7fa62cc Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 22:31:24 +0000 Subject: [PATCH 2/6] docs: state the maintenance cost, because "never used" understates it (#1143) A columnar insert touches very few buffers, so any index maintenance is a large multiple of it. Measured on 100,000 rows, shared buffer hits on the INSERT: columnar no index 202 with BRIN 37,484 heap no index 101,468 with BRIN 118,923 The index is the same size on both storages -- 24,576 bytes for BRIN and 5,726,208 for GIN -- so identical bytes on disk buy a usable index on one access method and an unusable one on the other. Measured independently by @jdatcmd, whose columnar delta of 36,968 agrees with the 37,282 here to within 0.8% on a different box. Their heap delta was 6,031 against 17,455 here, so the heap side is not stable between boxes and no RATIO between the two is stated; only the columnar figures, which reproduce. TWO CLAIMS DELIBERATELY LEFT OUT. A multiplier expressed as "N times heap", because the heap term does not reproduce. And "brin_summarize_new_values errors on a columnar table" -- reported by @jdatcmd, and on this box the same call returns 0 rather than raising, with the index created before the rows so the unsummarized state exists. One of the two conditions differs and it is not reconciled, so it is not in a user-facing page. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 7 +++++++ docs/limitations.md | 14 ++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 166b9093..9a026307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,13 @@ true until the next version shipped. | heap | Bitmap Heap Scan | Bitmap Heap Scan | | columnar | Seq Scan | Seq Scan | + The page now also states the maintenance cost, because "never used" understates + it. A columnar insert touches very few buffers, so any index maintenance is a large + multiple of it: on 100,000 rows an `INSERT` takes 202 shared hits with no index and + 37,484 with a BRIN index, against 101,468 and 118,923 on heap. The index is the same + size on both storages. The work is real and buys an index that cannot be chosen. + Measured by @jdatcmd and reproduced here, whose columnar delta agreed within 0.8%. + On PostgreSQL 18 the columnar plan carries `Disabled: true`, which is the planner reporting that it used a node it had been told not to use because no alternative path existed. A row count cannot change that. BRIN is settled by the same diff --git a/docs/limitations.md b/docs/limitations.md index 7e969081..e5f1dc40 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -713,6 +713,20 @@ used a node it had been told not to use, because the alternative did not exist. Queries still answer correctly, through a sequential scan or the custom scan. A `gin` or `brin` index on a columnar table is simply never read. +It is still maintained on every insert, and that cost is not small in proportion. +A columnar insert touches very few buffers, so any index maintenance is a large +multiple of it. Measured on 100,000 rows, shared buffer hits on the `INSERT` +itself: + +| table | no index | with BRIN | +| --- | ---: | ---: | +| columnar | 202 | 37,484 | +| heap | 101,468 | 118,923 | + +The index is also the same size on both storages, at 24,576 bytes for BRIN and +5,726,208 for GIN. The work is real and the bytes are real. On a columnar table +they buy an index that cannot be chosen. + **Use a GiST or an SP-GiST index for a selective overlap or containment query.** Both build on a columnar table and both answer the query. A columnar index scan is charged for the row-group decode its per-row fetches force. A broad query may From 7a60e9a48730d928d51a17f67ae78f14092810d0 Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 22:33:56 +0000 Subject: [PATCH 3/6] docs: BRIN never summarizes on a columnar table, and the refusal is reachable (#1143) Two readings that looked contradictory and are not. `brin_summarize_new_values` returns 0 on a columnar table where heap returns a count. `brin_summarize_range` on a range that has work raises: SELECT brin_summarize_range(cr_brin, 2); ERROR: columnar: partial-range index build is not supported A 0 is not evidence that summarization works. It means BRIN found no range to summarize and never called into the access method, so it is silence rather than success. The error is what the path does when it is actually reached. @jdatcmd found the guard in index_build_range_scan and reported the ERROR; I could not reproduce it and reported the 0. Neither of us was wrong and neither reading settled it. The condition is reproduced here by asking for a range that HAS work: ranges 0, 1 and 5 return 0 on the same index and the same table, range 2 raises. That is the fourth property of a BRIN index on a columnar table, after building, never being chosen, and costing real work on every insert. The index stays at its initial size because nothing ever summarizes into it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 8 ++++++++ docs/limitations.md | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a026307..420651d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,14 @@ true until the next version shipped. size on both storages. The work is real and buys an index that cannot be chosen. Measured by @jdatcmd and reproduced here, whose columnar delta agreed within 0.8%. + A BRIN index on a columnar table also never summarizes. `brin_summarize_new_values` + returns 0 where heap returns a count, and `brin_summarize_range` on a range that has + work raises "columnar: partial-range index build is not supported". The two readings + look contradictory and are not: a 0 means BRIN found no range and never called into + the access method, so it is silence rather than success. @jdatcmd found the guard in + `index_build_range_scan`; the condition that reaches it is reproduced here by naming + a range that has work. + On PostgreSQL 18 the columnar plan carries `Disabled: true`, which is the planner reporting that it used a node it had been told not to use because no alternative path existed. A row count cannot change that. BRIN is settled by the same diff --git a/docs/limitations.md b/docs/limitations.md index e5f1dc40..8f7c0023 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -727,6 +727,18 @@ The index is also the same size on both storages, at 24,576 bytes for BRIN and 5,726,208 for GIN. The work is real and the bytes are real. On a columnar table they buy an index that cannot be chosen. +A BRIN index also never summarizes. `brin_summarize_new_values` returns 0 on a +columnar table where it returns a count on heap, because it finds no range to +summarize. Asking it to summarize one range directly reaches a path that refuses: + +``` +SELECT brin_summarize_range('cr_brin', 2); +ERROR: columnar: partial-range index build is not supported +``` + +Ranges with nothing to summarize still return 0, so the error appears only for a +range that has work. The index therefore stays at its initial size. + **Use a GiST or an SP-GiST index for a selective overlap or containment query.** Both build on a columnar table and both answer the query. A columnar index scan is charged for the row-group decode its per-row fetches force. A broad query may From f42b05e7b8276cab4bc5b6211aba1e2e57ccb4ef Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 22:38:29 +0000 Subject: [PATCH 4/6] docs: the BRIN refusal is consumable, so a second check reports success (#1143) A range with work refuses ONCE. The next call on the same range returns 0 and the index never grows. Sweeping ranges 0 to 6 three times on one index: pass 1 0 0 E 0 0 0 0 index 24,576 bytes pass 2 0 0 0 0 0 0 0 index 24,576 bytes pass 3 0 0 0 0 0 0 0 index 24,576 bytes So a user who runs the documented maintenance function, sees an error, runs it again and sees 0 is told it was repaired. Nothing was summarized and the index is still empty. The page now says "only once" and shows the sweep, because the previous wording -- "the error appears only for a range that has work" -- is true and would let exactly that reader draw the wrong conclusion. IT ALSO EXPLAINS WHY NEITHER SESSION COULD REPRODUCE THE OTHER. @jdatcmd saw the ERROR and then could not get it again; I saw 0 and could not get the ERROR. Neither box changed and neither fixture was wrong. We were at different points in the same consumption sequence, and re-running the same call is precisely the thing that cannot distinguish them. Found by @jdatcmd sweeping a 526-page table; reproduced here on a 3-page one, which is why their pass showed five refusals and this one shows a single E. The mechanism is NOT claimed. The behaviour is consistent with the placeholder BRIN writes before the range scan marking the range, but neither of us has read that code or instrumented it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 8 ++++++++ docs/limitations.md | 13 +++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 420651d3..c92e0107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,14 @@ true until the next version shipped. `index_build_range_scan`; the condition that reaches it is reproduced here by naming a range that has work. + **The refusal is consumable, which is worse than the refusal.** The same range + returns 0 on the next call and the index never grows, so a reader who checks twice + is told the maintenance function worked. Sweeping ranges 0 to 6 three times gives + `0 0 E 0 0 0 0`, then all zeros, then all zeros, with the index at 24,576 bytes + throughout. That is also why neither session could reproduce the other's result by + re-running the same call: each of us was at a different point in the same + consumption sequence. + On PostgreSQL 18 the columnar plan carries `Disabled: true`, which is the planner reporting that it used a node it had been told not to use because no alternative path existed. A row count cannot change that. BRIN is settled by the same diff --git a/docs/limitations.md b/docs/limitations.md index 8f7c0023..05bf581e 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -736,8 +736,17 @@ SELECT brin_summarize_range('cr_brin', 2); ERROR: columnar: partial-range index build is not supported ``` -Ranges with nothing to summarize still return 0, so the error appears only for a -range that has work. The index therefore stays at its initial size. +Ranges with nothing to summarize return 0, so the error appears only for a range +that has work. **It also appears only once.** Sweeping the same ranges three times: + +| pass | ranges 0 to 6 | index size | +| --- | --- | ---: | +| 1 | 0 0 E 0 0 0 0 | 24,576 | +| 2 | 0 0 0 0 0 0 0 | 24,576 | +| 3 | 0 0 0 0 0 0 0 | 24,576 | + +So a second call reports 0 over an index that is still empty. Do not read that 0 as +a repair. The index never grew, and nothing was ever summarized into it. **Use a GiST or an SP-GiST index for a selective overlap or containment query.** Both build on a columnar table and both answer the query. A columnar index scan From 2e294366f2e4f1cca79fe7395a94053e5e22e7be Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 17:47:01 -0600 Subject: [PATCH 5/6] docs: the two index sizes are essentially equal, not identical (#1143) The page and the changelog both said the index is "the same size on both storages" and gave 24,576 bytes for BRIN and 5,726,208 for GIN. That is what this fixture measured, and the flat identity is stronger than one fixture supports. @jdatcmd ran it at a different row count and got BRIN equal on both storages again, but GIN 11,091,968 against 11,149,312 -- about 0.5% apart. The point the paragraph makes survives completely: the storage does not change what the index costs. What does not survive is "the same size" read as byte-for-byte. Both places now say "essentially the same size", keep the measured numbers, and name them as this fixture's rather than as a property. Fixed in the changelog as well as the page, because the claim appeared twice and correcting only the one I was shown would leave the other to be rediscovered. docs_style.sh: 55 checks, PASSED. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 6 ++++-- docs/limitations.md | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c92e0107..5a5d7a23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,8 +38,10 @@ true until the next version shipped. The page now also states the maintenance cost, because "never used" understates it. A columnar insert touches very few buffers, so any index maintenance is a large multiple of it: on 100,000 rows an `INSERT` takes 202 shared hits with no index and - 37,484 with a BRIN index, against 101,468 and 118,923 on heap. The index is the same - size on both storages. The work is real and buys an index that cannot be chosen. + 37,484 with a BRIN index, against 101,468 and 118,923 on heap. The index is essentially + the same size on both storages -- equal on this fixture, and about 0.5% apart on a + second run at a different row count. The work is real and buys an index that cannot + be chosen. Measured by @jdatcmd and reproduced here, whose columnar delta agreed within 0.8%. A BRIN index on a columnar table also never summarizes. `brin_summarize_new_values` diff --git a/docs/limitations.md b/docs/limitations.md index 05bf581e..71947315 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -723,9 +723,12 @@ itself: | columnar | 202 | 37,484 | | heap | 101,468 | 118,923 | -The index is also the same size on both storages, at 24,576 bytes for BRIN and -5,726,208 for GIN. The work is real and the bytes are real. On a columnar table -they buy an index that cannot be chosen. +The index is also essentially the same size on both storages: on this fixture, +24,576 bytes for BRIN and 5,726,208 for GIN on each. Read that as "the storage +does not change what the index costs", not as a byte-for-byte identity. A second +run at a different row count put the two GIN indexes about 0.5% apart rather than +exactly equal. The work is real and the bytes are real. On a columnar table they +buy an index that cannot be chosen. A BRIN index also never summarizes. `brin_summarize_new_values` returns 0 on a columnar table where it returns a count on heap, because it finds no range to From 33901ea1d37a7334182f8e48245556879073cbbb Mon Sep 17 00:00:00 2001 From: OffgridwithJD Date: Tue, 22 Sep 2026 17:50:03 -0600 Subject: [PATCH 6/6] docs: the second GIN run differed by fixture, not by row count (#1143) My previous commit explained the 0.5% discrepancy as "a second run at a different row count". Both runs were at 100,000 rows. What differs is the fixture: @jdatcmd indexed a text[] of three md5s per row, which is also why the absolute sizes are 11 MB against 5.7 MB rather than close. The clause exists to tell a reader WHY two measurements of the same property disagree, and it was pointing at the one variable that was held constant. Same number of words, and now a fact. Corrected in both places again, for the same reason as last time. docs_style.sh: 55 checks, PASSED. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MpajdQbkVJ9ey1XyYHcikP --- CHANGELOG.md | 2 +- docs/limitations.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a5d7a23..c023e97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ true until the next version shipped. multiple of it: on 100,000 rows an `INSERT` takes 202 shared hits with no index and 37,484 with a BRIN index, against 101,468 and 118,923 on heap. The index is essentially the same size on both storages -- equal on this fixture, and about 0.5% apart on a - second run at a different row count. The work is real and buys an index that cannot + second run on a different fixture. The work is real and buys an index that cannot be chosen. Measured by @jdatcmd and reproduced here, whose columnar delta agreed within 0.8%. diff --git a/docs/limitations.md b/docs/limitations.md index 71947315..8c021a81 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -726,7 +726,7 @@ itself: The index is also essentially the same size on both storages: on this fixture, 24,576 bytes for BRIN and 5,726,208 for GIN on each. Read that as "the storage does not change what the index costs", not as a byte-for-byte identity. A second -run at a different row count put the two GIN indexes about 0.5% apart rather than +run on a different fixture put the two GIN indexes about 0.5% apart rather than exactly equal. The work is real and the bytes are real. On a columnar table they buy an index that cannot be chosen.