TimescaleDB 2.27, released May 12 2026, extends bloom-filter batch pruning from reads to writes. UPDATE, DELETE, and UPSERT against compressed columnstore data can now skip decompressing batches that provably cannot contain the target rows. The reported gains are real: up to 160x for selective UPDATE/DELETE, and over 2x for UPSERT.
The feature is automatic. Whether it is actually firing on your workload is not something you can assume, and the only way to confirm it is to read new EXPLAIN counters that the release notes mention but do not explain. Worse, the counter names are inconsistent between the write paths, so even a careful reader ends up guessing.
This post is about reading those counters correctly, and about the two things in this release that will silently break a query if you upgrade without noticing them.
What is actually being skipped
A quick model of the mechanism, because the counters only make sense against it.
Hypercore stores compressed data in batches, roughly a thousand rows each. For columns that are not the segmentby key, TimescaleDB maintains a sparse bloom filter per batch: a small probabilistic summary that answers one question, "could this batch contain column = X?", without touching the compressed payload.
A bloom filter has a useful asymmetry. A negative is certain: if the filter says no, the value is definitely absent, and the batch can be skipped whole. A positive is not: the filter says "maybe", you decompress, and sometimes the value is not there after all. That last case is a false positive, and it is the number that tells you whether the whole scheme is paying off.
Before 2.27, a DELETE ... WHERE sensor_id = 'x' against compressed data decompressed every candidate batch to check. Now the bloom filter is consulted first, and batches that cannot match are never decompressed. The work you save is the decompression of the batches that get pruned. The work you waste, when the filter is poorly matched to your data, is the bloom check on top of decompression you end up doing anyway.
The counters, and why they don't match
Here is the trap. The two write paths report their pruning with different vocabulary, and nothing in the documentation reconciles them.
For UPDATE and DELETE, EXPLAIN (ANALYZE) adds:
- Compressed batches filtered — batches skipped by the bloom filter without decompression. This is the win. Higher is better.
- Batches filtered after decompression — batches that passed the bloom check, got decompressed, and then turned out not to match. This is the waste, and it is your false-positive signal on the read-modify path.
For UPSERT (INSERT ... ON CONFLICT), the same release reports a completely different set:
- batches checked by bloom — how many batches the filter was consulted for.
- batches pruned by bloom — how many were skipped. The win.
- batches without bloom — batches that had no applicable filter, so they were decompressed unconditionally. A blind spot.
- bloom false positives — filter said maybe, decompression said no.
Same underlying mechanism, two naming schemes. "Compressed batches filtered" on the DELETE path and "batches pruned by bloom" on the UPSERT path are the same quantity. "Batches filtered after decompression" and "bloom false positives" are the same quantity. If you profile an UPSERT and a DELETE in the same session and expect the same labels, you will not find them.
The UPSERT output is the more diagnostic of the two because it exposes batches without bloom, the count of batches that had no filter to consult at all. On the UPDATE/DELETE path that category is invisible: those batches simply show up as decompressed, indistinguishable from batches that were checked and matched. When you are debugging why pruning is not happening, the UPSERT path tells you more.
Reading it in practice
The number that matters is the ratio of pruned to total. Consider a DELETE:
EXPLAIN (ANALYZE, VERBOSE)
DELETE FROM metrics
WHERE device_id = 'sensor-4417';
Enter fullscreen mode Exit fullscreen mode
A healthy result on selective data looks like a high "Compressed batches filtered" and a low "Batches filtered after decompression". The filter is doing its job: most batches are eliminated cold, and the few that get decompressed mostly contain real matches.
The pathological result is the inverse: low pruning, high post-decompression filtering. That means the bloom filter is saying "maybe" for batches that do not contain your value, you are decompressing them anyway, and the bloom check is pure overhead added on top of the work you were already doing. Two things cause this.
Cause one: the value is spread across every batch. Bloom pruning only helps when your target values are physically clustered into a subset of batches. If device_id is effectively random with respect to insertion order, every batch contains a bit of every device, no batch can be ruled out, and the filter prunes nothing. This is a data-layout problem, not a bug. The fix is segmentby on the column you filter by, so rows for one device land in the same batches, or ordering ingestion so the column clusters.
Cause two: there is no bloom filter on the column. By default, with timescaledb.auto_sparse_indexes on, TimescaleDB derives bloom filters from your existing B-tree index patterns and column types. If you filter on a column that has no B-tree index and was never named in a manual sparse-index definition, there may be no filter to consult. On the UPSERT path you see this directly as "batches without bloom". On the DELETE path you have to infer it from pruning being suspiciously close to zero.
The two changes that will break a query on upgrade
This release has two backward-incompatible items in the notes. Both are easy to skip and both bite silently.
int2 bloom filters can return wrong results, and block the upgrade. Per the 2.27 notes, bloom filter sparse indexes on compressed int2 (smallint) columns could cause SELECT queries to not return rows that actually match the WHERE clause. This is a correctness bug, not a performance one: right query, right data, missing rows. The upgrade is blocked for affected databases, and you must drop the incorrect indexes manually before it will proceed. If you have smallint columns in compressed chunks with bloom filters, this is the item to read twice.
Composite bloom filters from 2.26 stop being used. 2.27 introduced a new naming convention for composite bloom-filter metadata. Filters you built under 2.26 will not disrupt queries, but 2.27 will not use them either. They silently stop pruning, and your multi-column predicates quietly get slower with no error and no log line. Converting them is a catalog-only rename, no recompression, via the migration script Timescale ships in the timescaledb-extras repository. If your 2.26 performance depended on composite bloom filters, run it, then confirm with EXPLAIN that pruning came back.
That second one is the nastier of the two precisely because nothing fails. The only symptom is a query that used to be fast becoming slow, which is exactly the kind of regression that gets blamed on everything except a metadata naming change buried in a minor-version changelog.
If you take one thing from this
Run EXPLAIN (ANALYZE) on your heaviest compressed UPDATE, DELETE, and UPSERT after upgrading, and look at the pruning ratio, not the wall-clock time. Wall-clock tells you it got faster or slower. The counters tell you why, and whether the headline 160x is something your data layout can actually reach or something you are leaving on the table because your filter column is not clustered.
The feature is automatic. Verifying it did anything is not.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.