TiDB scans unnecessary inputs for statically empty INTERSECT and EXCEPT results
- 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
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