#10606·timescaledb

[Bug]: Chunks are not excluded for a hypertable on the parameterized side of a nested loop

Author: h0rn3tCreated Sep 16, 2026Updated Sep 16, 2026

What type of bug is this?

Performance issue

What subsystems and features are affected?

Query planner, Partitioning

What happened?

When a hypertable with a hash (space) dimension is on the parameterized side of a nested loop, and the join qualifier is on the partitioning column, every chunk is probed for every outer row. Three plans over the same table, the same time range and the same three keys:

  • A — key comes from a join parameter: plain Append with all 12 space chunks. On every one of the 3 outer rows all 12 chunks are probed; 9 of them return 0 rows.
  • B — the same query plus an always-true qual ts > now() - interval '100 years': the node changes to Custom Scan (ChunkAppend), but it reports Chunks excluded during runtime: 0, so the same 9 chunks are still probed on every outer row.
  • C — the same three keys written as plan-time constants: only 3 chunks end up in the plan.

The three keys hash into 3 of the 12 partitions (verified against _timescaledb_catalog.dimension_slice), so the 9 chunks skipped in C are exactly the ones probed for nothing in A and B.

What I expected: in the join form the chunks that cannot match the current parameter value are not probed, as in C. I also did not expect the choice between Append and ChunkAppend to depend on an unrelated always-true qualifier — A and B differ only by that qual.

Reproduces identically on 2.29.1 and on 2.30.0.

TimescaleDB version affected

2.29.1 (also reproduced on 2.30.0)

PostgreSQL version used

17.10 (also reproduced on 17.11 with TimescaleDB 2.30.0)

What operating system did you use?

Docker, timescale/timescaledb:2.29.1-pg17 and :latest-pg17 (Alpine, x86_64)

What installation method did you use?

Docker

What platform did you run on?

On prem/Self-hosted

Relevant log output and stack trace

=============== A: join parameter only ===============
 Aggregate (actual rows=1 loops=1)
   ->  Nested Loop (actual rows=72 loops=1)
         ->  Values Scan on "*VALUES*" (actual rows=3 loops=1)
         ->  Append (actual rows=24 loops=3)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_2_50_chunk m_1 (actual rows=8 loops=3)
                     Index Cond: ((device_id = "*VALUES*".column1) AND (ts >= '2025-02-01 00:00:00+00') AND (ts < '2025-02-02 00:00:00+00'))
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_2_54_chunk m_2 (actual rows=8 loops=3)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_2_58_chunk m_3 (actual rows=8 loops=3)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_2_62_chunk m_4 (actual rows=0 loops=3)
               ...  (12 chunk nodes in total, 9 of them with actual rows=0 loops=3)

=============== B: same query + AND ts > now() - interval '100 years' ===============
 Aggregate (actual rows=1 loops=1)
   ->  Nested Loop (actual rows=72 loops=1)
         ->  Values Scan on "*VALUES*" (actual rows=3 loops=1)
         ->  Custom Scan (ChunkAppend) on metric m (actual rows=24 loops=3)
               Chunks excluded during startup: 0
               Chunks excluded during runtime: 0
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_1_2_chunk m_1 (actual rows=8 loops=3)
                     Index Cond: ((device_id = "*VALUES*".column1) AND (ts >= ...) AND (ts < ...) AND (ts > (now() - '100 years'::interval)))
               ...  (12 chunk nodes in total, 9 of them with actual rows=0 loops=3)

=============== C: same keys as plan-time constants ===============
 Finalize Aggregate (actual rows=1 loops=1)
   ->  Append (actual rows=3 loops=1)
         ->  Partial Aggregate (actual rows=1 loops=1)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_1_2_chunk m_1 (actual rows=24 loops=1)
                     Index Cond: ((device_id = ANY ('{1,2,3}'::bigint[])) AND (ts >= ...) AND (ts < ...))
         ->  Partial Aggregate (actual rows=1 loops=1)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_1_6_chunk m_2 (actual rows=24 loops=1)
         ->  Partial Aggregate (actual rows=1 loops=1)
               ->  Index Only Scan using ..._chunk_metric_device_id_ts_idx on _hyper_1_10_chunk m_3 (actual rows=24 loops=1)
         (3 chunk nodes in total)

=============== hash partitions touched by keys 1,2,3 ===============
 total_partitions | partitions_touched
------------------+--------------------
               12 |                  3

How can we reproduce the bug?

bash
docker run -d --name tsdb -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=repro \
  -p 5432:5432 timescale/timescaledb:2.29.1-pg17
psql -h localhost -U postgres -d repro -f repro.sql

repro.sql:

sql
CREATE EXTENSION IF NOT EXISTS timescaledb;

CREATE TABLE metric (
    ts        timestamptz NOT NULL,
    device_id bigint      NOT NULL,
    val       double precision
);
SELECT create_hypertable('metric', by_range('ts', INTERVAL '30 days'));
SELECT add_dimension('metric', by_hash('device_id', 12));

INSERT INTO metric
SELECT g, d, random()
FROM generate_series('2025-01-01'::timestamptz, '2025-03-31', '1 hour') g,
     generate_series(1, 500) d;

CREATE INDEX ON metric (device_id, ts);
VACUUM ANALYZE metric;

-- put the hypertable on the parameterized side of a nested loop
SET enable_hashjoin = off;
SET enable_mergejoin = off;
SET enable_material = off;
SET max_parallel_workers_per_gather = 0;

-- A: key comes from a join parameter -> plain Append, all 12 space chunks probed per outer row
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, BUFFERS OFF, SUMMARY OFF)
SELECT count(*)
FROM (VALUES (1::bigint), (2), (3)) v(device_id),
LATERAL (
    SELECT 1 FROM metric m
    WHERE m.device_id = v.device_id
      AND m.ts >= '2025-02-01'::timestamptz
      AND m.ts <  '2025-02-02'::timestamptz
) x;

-- B: same query + always-true qual -> ChunkAppend, but "Chunks excluded during runtime: 0"
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, BUFFERS OFF, SUMMARY OFF)
SELECT count(*)
FROM (VALUES (1::bigint), (2), (3)) v(device_id),
LATERAL (
    SELECT 1 FROM metric m
    WHERE m.device_id = v.device_id
      AND m.ts >= '2025-02-01'::timestamptz
      AND m.ts <  '2025-02-02'::timestamptz
      AND m.ts > now() - interval '100 years'
) x;

-- C: reference, same keys as plan-time constants -> only 3 chunks in the plan
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, BUFFERS OFF, SUMMARY OFF)
SELECT count(*) FROM metric m
WHERE m.device_id = ANY (ARRAY[1, 2, 3]::bigint[])
  AND m.ts >= '2025-02-01'::timestamptz
  AND m.ts <  '2025-02-02'::timestamptz;

-- keys 1,2,3 hash into 3 of the 12 partitions
WITH ids(device_id) AS (SELECT unnest(ARRAY[1, 2, 3]::bigint[])),
slices AS (
  SELECT DISTINCT ds.range_start, ds.range_end
  FROM _timescaledb_catalog.dimension d
  JOIN _timescaledb_catalog.hypertable h ON h.id = d.hypertable_id AND h.table_name = 'metric'
  JOIN _timescaledb_catalog.dimension_slice ds ON ds.dimension_id = d.id
  WHERE d.column_name = 'device_id'
)
SELECT (SELECT count(*) FROM slices) AS total_partitions,
       count(DISTINCT s.range_start) AS partitions_touched
FROM ids
JOIN slices s ON _timescaledb_functions.get_partition_hash(ids.device_id) >= s.range_start
             AND _timescaledb_functions.get_partition_hash(ids.device_id) <  s.range_end;

All relevant GUCs are at their defaults (timescaledb.enable_chunk_append, timescaledb.enable_runtime_exclusion, timescaledb.enable_constraint_exclusion = on).