pingcap / pingcap/tidb

TiDB scans unnecessary inputs for statically empty INTERSECT and EXCEPT results

Open
#70,051 2 comments 0 reactions 0 assignees View on GitHub
contribution severity/moderate sig/planner type/bug
Dominant language
Go
Stars
40.5k
Forks
6.2k
PR merge metrics
PR metrics pending

Description

## Bug Report

Please answer these questions before submitting your issue. Thanks!

TiDB represents a `WHERE FALSE` input using `TableDual(rows:0)`, but does not
always propagate that emptiness through the set operation.

### 1. Minimal reproduce step (Required)

```sql
DROP DATABASE IF EXISTS tidb_empty_setop;
CREATE DATABASE tidb_empty_setop;
USE tidb_empty_setop;
CREATE TABLE digits(d INT PRIMARY KEY);
INSERT INTO digits VALUES(0),(1),(2),(3),(4),(5),(6),(7),(8),(9);
CREATE TABLE lhs(id INT);
CREATE TABLE rhs(id INT);
INSERT INTO lhs SELECT a.d+b.d*10+c.d*100+d.d*1000+e.d*10000+1
FROM digits a,digits b,digits c,digits d,digits e;
INSERT INTO rhs SELECT a.d+b.d*10+c.d*100+d.d*1000+e.d*10000+1
FROM digits a,digits b,digits c,digits d,digits e;
ANALYZE TABLE lhs,rhs;

EXPLAIN SELECT COUNT(*) FROM
((SELECT id FROM lhs) INTERSECT (SELECT id FROM rhs WHERE FALSE)) s;
SELECT COUNT(*) FROM
((SELECT id FROM lhs) INTERSECT (SELECT id FROM rhs WHERE FALSE)) s;

EXPLAIN SELECT COUNT(*) FROM
((SELECT id FROM lhs WHERE FALSE) EXCEPT (SELECT id FROM rhs)) s;
SELECT COUNT(*) FROM
((SELECT id FROM lhs WHERE FALSE) EXCEPT (SELECT id FROM rhs)) s;

-- Mutated form for both original queries.
EXPLAIN SELECT COUNT(*) FROM (SELECT id FROM lhs WHERE FALSE) s;
SELECT COUNT(*) FROM (SELECT id FROM lhs WHERE FALSE) s;
```

### 2. What did you expect to see? (Required)

`A INTERSECT empty` and `empty EXCEPT B` should both be replaced with an empty
result without scanning `A` or `B`.

### 3. What did you see instead (Required)

For `INTERSECT`, TiDB builds a semi join with an empty build side but still
scans and aggregates `lhs`. For left-empty `EXCEPT`, it builds an anti-semi join
and still scans `rhs`.

| Case | Original set query | Mutated query after branch removal | Difference |
|---|---:|---:|---:|
| A INTERSECT empty | 21.099 ms | 0.604 ms | 34.94x |
| empty EXCEPT B | 25.448 ms | 0.596 ms | 42.73x |

### Execution-plan evidence and decision

All paired forms return `COUNT(*) = 0`, with timings from seven alternating
executions. Their plans distinguish the optimization miss from runtime noise:

```text
Original A INTERSECT empty Original empty EXCEPT B
HashAgg(count) StreamAgg(count)
`- HashJoin(semi) `- HashJoin(anti semi)
|- TableDual(rows:0) |- TableFullScan(rhs, 100000 rows)
`- TableFullScan(lhs, 100000) `- TableDual(rows:0)

Mutated (both cases)
StreamAgg(count)
`- TableDual(rows:0) -- no base-table scan
```

The 34.94x and 42.73x differences thus correspond to full scans visible only
in the original plans.

### 4. What is your TiDB version? (Required)

```text
Release Version: v8.5.7
Edition: Community
Git Commit Hash: 202b7f47286a1109b5c957401d34c9358d130ae0
UTC Build Time: 2026-07-15 02:06:00
Store: unistore
```

Contributor guide

Open the contributing guide

Research direction

Start by running the supplied SQL reproduction and comparing the EXPLAIN plans for the INTERSECT, EXCEPT, and branch-removed forms. Trace the set-operation planning path responsible for the semi and anti-semi joins; done means statically empty inputs produce an empty result without scanning the other table, while the queries still return COUNT(*) = 0.

Written by the indexing model from the issue text.

Assessment

Tech stack
go, sql
Domain
databases, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.