Company or project name
No response
Describe what's wrong
A GROUP BY k on top of a JOIN whose left side is a SELECT DISTINCT subquery intermittently returns the same group key in two separate rows, with the aggregate values split between them. The sum across the duplicate rows always equals the correct single-row value, so no data is lost or duplicated, the group is emitted in more than one piece.
The same query text, same data, same settings returns 3 rows most of the time and 4 rows occasionally (~4–10% under server defaults). system.query_log confirms identical Settings and identical query across both outcomes, so the plan is identical between a correct and an incorrect run.
Turning off optimize_distinct_in_order eliminates it completely. Replacing parallel_hash with any other join algorithm eliminates it completely. Rewriting SELECT DISTINCT a,b,c as SELECT a,b,c GROUP BY a,b,c (semantically identical, verified byte-identical output) also eliminates it completely.
Reproducer with sample data attached , see "How to reproduce" below for the full CREATE TABLE + load + query script.
Does it reproduce on the most recent release?
Yes
How to reproduce
Reproduced on ClickHouse Cloud 26.2.1.558. Have not independently verified against the latest OSS release, happy to if a maintainer confirms it would help.
- ClickHouse server version: 26.2.1.558 (ClickHouse Cloud)
- Interface: observed via both the native protocol (Go driver) and HTTP
- Non-default settings: NONE required, reproduces under pure server defaults. Forcing
join_algorithm='parallel_hash', max_threads=4 raises the failure rate from ~5–25% to ~85–95%.
Sample data attached below (dim_part1..6.tsv.gz, facts.tsv.gz — load order matters, see load step below).
CREATE DATABASE IF NOT EXISTS repro;
CREATE TABLE repro.dim
(
sync_version UInt64,
is_deleted Bool,
tenant_id UInt64,
grp LowCardinality(String), -- the GROUP BY key
k3 UInt64, -- sort-key filler
k2 Int64, -- join key
k4 UInt64, -- sort-key filler
k5 Date, -- sort-key filler
k1 String -- join key
)
ENGINE = ReplacingMergeTree(sync_version)
ORDER BY (tenant_id, grp, k3, k2, k4, k5)
SETTINGS max_bytes_to_merge_at_max_space_in_pool = 1; -- keep the loaded parts distinct
CREATE TABLE repro.facts
(
tenant_id UInt64,
d Date,
input_id UInt64,
load_id UUID,
grp LowCardinality(String),
k2 Int64,
k1 String,
k6 String, -- sort-key filler
k7 String, -- sort-key filler
measure UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(d)
ORDER BY (tenant_id, d, input_id, load_id, grp, k2, k1, k6, k7);
dim_part1.tsv.gz
dim_part2.tsv.gz
dim_part3.tsv.gz
dim_part4.tsv.gz
dim_part5.tsv.gz
dim_part6.tsv.gz
facts.tsv.gz
Expected behavior
GROUP BY should return each group key exactly once. Instead, under the conditions above, one key is intermittently split across two rows whose values sum to the correct total.
Error message and/or stacktrace
None, no exception is raised. The query completes successfully but returns incorrect (split) results.
Related issues and pull requests
Related: #109216
Additional context
Environment
- ClickHouse Cloud, version 26.2.1.558 (official build)
join_algorithm = direct,parallel_hash,hash (server default)
max_threads = auto(N); observed at both 3 and 6 as the service scaled
optimize_distinct_in_order = 1 (default)
distributed_group_by_no_merge = 0 (default)
enable_parallel_replicas = 0
- Single-node query (not distributed)
Reproducing query (structure)
Schema shape (names genericized):
-- left/dimension table
CREATE TABLE dim
(
sync_version UInt64,
is_deleted Bool,
tenant_id UInt64,
grp LowCardinality(String), -- the GROUP BY key
k1 String, -- join key
k2 Int64 -- join key
)
ENGINE = ReplacingMergeTree(sync_version)
ORDER BY (tenant_id, grp, k1, k2, ...); -- NOTE: grp is a sort-key prefix column
-- right/fact table
CREATE TABLE facts
(
tenant_id UInt64,
d Date,
k1 String,
k2 Int64,
measure UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(d)
ORDER BY (tenant_id, d, ..., k1, k2, ...);
Query:
SELECT
m.grp,
sum(a.measure) AS cnt
FROM (
SELECT DISTINCT grp, k1, k2
FROM dim FINAL
WHERE tenant_id = ? AND is_deleted = 0
) AS m
INNER JOIN (
SELECT k1, k2, measure
FROM facts
WHERE tenant_id = ? AND d BETWEEN ? AND ?
) AS a USING (k1, k2)
GROUP BY m.grp
Relevant data characteristics: the left side yields ~580k distinct rows across 3 values of grp, heavily skewed — one group holds ~97% of the rows, the other two are small. Only the large group ever splits; the small ones never do. The dim table has multiple active parts (EXPLAIN PIPELINE shows a 9-way ReplacingSorted/MergeTreeSelect(ReadPoolInOrder, InOrder) fan-in).
Note: this simplified shape is illustrative only and did not reproduce standalone in our testing, see "How to reproduce" above for the exact schema/data that does.
Observed result
Correct (majority of runs):
grp_small_1 828
grp_small_2 173923
grp_large 224701
Incorrect (same query, same settings, ~4–10% of runs):
grp_large 254 <-- same key
grp_small_1 828
grp_small_2 173923
grp_large 224447 <-- same key
254 + 224447 = 224701 — exactly the correct value. The split point differs on every occurrence (observed 15341/209360, 21190/203511, 28030/196671, …), while the total is always identical.
Evidence that the plan is identical between correct and incorrect runs
40 executions of the identical query under defaults, then:
SELECT result_rows, uniqExact(Settings) AS distinct_setting_sets, uniqExact(query) AS distinct_queries
FROM system.query_log
WHERE query LIKE '%<marker>%' AND type = 'QueryFinish'
GROUP BY result_rows
result_rows distinct_setting_sets distinct_queries
3 1 1
4 1 1
Same query text, same settings on both sides — so this is probably a runtime race, not a plan/semantics difference.
EXPLAIN PLAN sorting=1 — the only difference between broken and fixed
optimize_distinct_in_order = 1 (broken):
Aggregating
Expression
Join (JOIN FillRightFirst)
Expression (Left Pre Join Actions)
Distinct (DISTINCT)
Distinct (Preliminary DISTINCT)
Sorting: __table2.grp ASC <-- only in the broken plan
ReadFromMergeTree (dim)
Sorting: tenant_id ASC, grp ASC <-- only in the broken plan
optimize_distinct_in_order = 0 (correct): byte-identical plan minus the two Sorting: lines.
EXPLAIN PIPELINE differs in exactly one transform:
broken: DistinctSortedStreamTransform × 4
correct: DistinctTransform × 4
Everything else — Resize 4 → 1, the final single-stream DistinctTransform, Resize 1 → 4, JoiningTransform × 4, AggregatingTransform × 4 — is identical.
Note that in both plans the Distinct (DISTINCT) step is fully below the join, i.e. deduplication completes on a single stream before any join work happens.
Controls (each figure is an independent measurement on the same data)
| variation |
incorrect runs |
| defaults, nothing changed |
6/60 and 10/60 in two samples; 31/707 (~4.4%) across all logged runs |
join_algorithm='parallel_hash', max_threads=4 |
23/24, 20/24, 19/20 (worst case) |
join_algorithm='parallel_hash', max_threads = 1 / 2 / 3 / 4 / 5 / 6 / 7 / 8 |
0/24, 1/24, 20/24, 23/24, 8/24, 1/24, 1/24, 0/24 |
optimize_distinct_in_order = 0 |
0/60 (defaults) and 0/24 (forced worst case) |
join_algorithm = 'hash' |
0/168 (across max_threads 1–8) |
grace_hash / partial_merge / full_sorting_merge |
0/192 each |
SELECT DISTINCT a,b,c rewritten as SELECT a,b,c ... GROUP BY a,b,c |
0/292 |
optimize_read_in_order = 0 and optimize_aggregation_in_order = 0 do not help (15/16 and 14/16 still incorrect), and neither removes the Sorting: annotation from the plan — the annotation and the bug appear and disappear together across every variation tested.
The DISTINCT subquery on its own is always correct: run standalone 50 times under pure defaults it returned the same 580865 rows every time, with zero duplicate (grp, k1, k2) tuples — including for tuples that genuinely have multiple physical rows in the table. SELECT DISTINCT a,b,c and SELECT a,b,c GROUP BY a,b,c produce identical row counts and identical sum(cityHash64(...)) checksums.
Oddity: CAST vs toString
Wrapping the group key in toString(grp) keeps the bug (16/20, 20/20). Wrapping it in CAST(grp AS String) removes it entirely (0/20 forced, 0/30 defaults), even though:
- both produce identical output (same row count, same checksum),
- both keep the
Sorting: annotation in EXPLAIN PLAN (toString(...) ASC vs CAST(..., 'String') ASC),
- and both produce a byte-identical
EXPLAIN PIPELINE, including DistinctSortedStreamTransform × 4.
We could not explain this from EXPLAIN output alone; mentioning it in case it narrows down where the two paths diverge internally.
Why we believe this is a bug rather than expected behaviour
SELECT k, agg(...) ... GROUP BY k returning the same k in more than one row contradicts the definition of GROUP BY, independently of what the subquery below it does. The only setting we found that legitimately permits partial, unmerged group output — distributed_group_by_no_merge — is 0 (default), and this is not a distributed query.
Related
Likely the same family as #109216 (partial_merge + optimize_distinct_in_order → wrong DISTINCT results, sort-order property claimed across a join that does not preserve it). Same control setting, same "assume a stream property the join does not actually preserve" shape, different join algorithm and different direction (there DISTINCT above the join, here below it).
Minimal reproducible example
Attached: dim_part1..6.tsv.gz + facts.tsv.gz and the schema/load/query script below.
Schema
CREATE DATABASE IF NOT EXISTS repro;
-- Column order matches the TSV files exactly -- FORMAT TSV maps positionally,
-- not by name, so do not reorder these.
CREATE TABLE repro.dim
(
sync_version UInt64,
is_deleted Bool,
tenant_id UInt64,
grp LowCardinality(String), -- the GROUP BY key
k3 UInt64, -- sort-key filler
k2 Int64, -- join key
k4 UInt64, -- sort-key filler
k5 Date, -- sort-key filler
k1 String -- join key
)
ENGINE = ReplacingMergeTree(sync_version)
ORDER BY (tenant_id, grp, k3, k2, k4, k5)
SETTINGS max_bytes_to_merge_at_max_space_in_pool = 1; -- keep the loaded parts distinct, see below
CREATE TABLE repro.facts
(
tenant_id UInt64,
d Date,
input_id UInt64,
load_id UUID,
grp LowCardinality(String),
k2 Int64,
k1 String,
k6 String, -- sort-key filler
k7 String, -- sort-key filler
measure UInt64
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(d)
ORDER BY (tenant_id, d, input_id, load_id, grp, k2, k1, k6, k7);
Load
Load the six dim files in order — each becomes exactly one part, and the resulting part-size skew (four ~560–590k-row parts, two tiny ones) is load-bearing:
for f in dim_part1.tsv.gz dim_part2.tsv.gz dim_part3.tsv.gz \
dim_part4.tsv.gz dim_part5.tsv.gz dim_part6.tsv.gz; do
gzip -dc "$f" | clickhouse-client --query "INSERT INTO repro.dim FORMAT TSV"
done
gzip -dc facts.tsv.gz | clickhouse-client --query "INSERT INTO repro.facts FORMAT TSV"
Row counts after loading: dim = 2,288,189 (collapses to 581,668 distinct (grp,k1,k2) triples under FINAL+DISTINCT), facts = 450,338 rows / 225,473 distinct (k1,k2) pairs.
Query, run repeatedly
SELECT m.grp, sum(a.measure) AS cnt
FROM (
SELECT DISTINCT grp, k1, k2
FROM repro.dim FINAL
WHERE tenant_id = 1 AND is_deleted = 0
) AS m
INNER JOIN (
SELECT k1, k2, measure
FROM repro.facts
WHERE tenant_id = 1 AND d BETWEEN '2026-08-08' AND '2026-08-09'
) AS a USING (k1, k2)
GROUP BY m.grp
Under pure server defaults (nothing set), this split 5/20, then 1/20 in two independent 20-run samples on a freshly loaded service — no dependency on the originating instance. Forcing join_algorithm='parallel_hash', max_threads=4 raised it to 17/20 and 19/20 in two samples on that same fresh service.
Correct:
code_2 266225
code_3 182985
code_5 1128
Incorrect (same query, same data, same settings — code_2 appears twice, split):
code_2 5133
code_2 261092
code_3 182985
code_5 1128
5133 + 261092 = 266225 — the correct total, matching the sum-preservation pattern from the original observation above.
We're happy to run any candidate diagnostic query against this same reloaded, anonymized dataset and report back.
Company or project name
No response
Describe what's wrong
A
GROUP BY kon top of a JOIN whose left side is aSELECT DISTINCTsubquery intermittently returns the same group key in two separate rows, with the aggregate values split between them. The sum across the duplicate rows always equals the correct single-row value, so no data is lost or duplicated, the group is emitted in more than one piece.The same query text, same data, same settings returns 3 rows most of the time and 4 rows occasionally (~4–10% under server defaults).
system.query_logconfirms identicalSettingsand identicalqueryacross both outcomes, so the plan is identical between a correct and an incorrect run.Turning off
optimize_distinct_in_ordereliminates it completely. Replacingparallel_hashwith any other join algorithm eliminates it completely. RewritingSELECT DISTINCT a,b,casSELECT a,b,c GROUP BY a,b,c(semantically identical, verified byte-identical output) also eliminates it completely.Reproducer with sample data attached , see "How to reproduce" below for the full CREATE TABLE + load + query script.
Does it reproduce on the most recent release?
Yes
How to reproduce
Reproduced on ClickHouse Cloud 26.2.1.558. Have not independently verified against the latest OSS release, happy to if a maintainer confirms it would help.
join_algorithm='parallel_hash', max_threads=4raises the failure rate from ~5–25% to ~85–95%.Sample data attached below (dim_part1..6.tsv.gz, facts.tsv.gz — load order matters, see load step below).
dim_part1.tsv.gz
dim_part2.tsv.gz
dim_part3.tsv.gz
dim_part4.tsv.gz
dim_part5.tsv.gz
dim_part6.tsv.gz
facts.tsv.gz
Expected behavior
GROUP BY should return each group key exactly once. Instead, under the conditions above, one key is intermittently split across two rows whose values sum to the correct total.
Error message and/or stacktrace
None, no exception is raised. The query completes successfully but returns incorrect (split) results.
Related issues and pull requests
Related: #109216
Additional context
Environment
join_algorithm=direct,parallel_hash,hash(server default)max_threads=auto(N); observed at both 3 and 6 as the service scaledoptimize_distinct_in_order= 1 (default)distributed_group_by_no_merge= 0 (default)enable_parallel_replicas= 0Reproducing query (structure)
Schema shape (names genericized):
Query:
Relevant data characteristics: the left side yields ~580k distinct rows across 3 values of
grp, heavily skewed — one group holds ~97% of the rows, the other two are small. Only the large group ever splits; the small ones never do. Thedimtable has multiple active parts (EXPLAIN PIPELINEshows a 9-wayReplacingSorted/MergeTreeSelect(ReadPoolInOrder, InOrder)fan-in).Note: this simplified shape is illustrative only and did not reproduce standalone in our testing, see "How to reproduce" above for the exact schema/data that does.
Observed result
Correct (majority of runs):
Incorrect (same query, same settings, ~4–10% of runs):
254 + 224447 = 224701— exactly the correct value. The split point differs on every occurrence (observed15341/209360,21190/203511,28030/196671, …), while the total is always identical.Evidence that the plan is identical between correct and incorrect runs
40 executions of the identical query under defaults, then:
Same query text, same settings on both sides — so this is probably a runtime race, not a plan/semantics difference.
EXPLAIN PLAN sorting=1— the only difference between broken and fixedoptimize_distinct_in_order = 1(broken):optimize_distinct_in_order = 0(correct): byte-identical plan minus the twoSorting:lines.EXPLAIN PIPELINEdiffers in exactly one transform:Everything else —
Resize 4 → 1, the final single-streamDistinctTransform,Resize 1 → 4,JoiningTransform × 4,AggregatingTransform × 4— is identical.Note that in both plans the
Distinct (DISTINCT)step is fully below the join, i.e. deduplication completes on a single stream before any join work happens.Controls (each figure is an independent measurement on the same data)
join_algorithm='parallel_hash', max_threads=4join_algorithm='parallel_hash',max_threads= 1 / 2 / 3 / 4 / 5 / 6 / 7 / 8optimize_distinct_in_order = 0join_algorithm = 'hash'max_threads1–8)grace_hash/partial_merge/full_sorting_mergeSELECT DISTINCT a,b,crewritten asSELECT a,b,c ... GROUP BY a,b,coptimize_read_in_order = 0andoptimize_aggregation_in_order = 0do not help (15/16 and 14/16 still incorrect), and neither removes theSorting:annotation from the plan — the annotation and the bug appear and disappear together across every variation tested.The
DISTINCTsubquery on its own is always correct: run standalone 50 times under pure defaults it returned the same 580865 rows every time, with zero duplicate(grp, k1, k2)tuples — including for tuples that genuinely have multiple physical rows in the table.SELECT DISTINCT a,b,candSELECT a,b,c GROUP BY a,b,cproduce identical row counts and identicalsum(cityHash64(...))checksums.Oddity:
CASTvstoStringWrapping the group key in
toString(grp)keeps the bug (16/20, 20/20). Wrapping it inCAST(grp AS String)removes it entirely (0/20 forced, 0/30 defaults), even though:Sorting:annotation inEXPLAIN PLAN(toString(...) ASCvsCAST(..., 'String') ASC),EXPLAIN PIPELINE, includingDistinctSortedStreamTransform × 4.We could not explain this from
EXPLAINoutput alone; mentioning it in case it narrows down where the two paths diverge internally.Why we believe this is a bug rather than expected behaviour
SELECT k, agg(...) ... GROUP BY kreturning the samekin more than one row contradicts the definition ofGROUP BY, independently of what the subquery below it does. The only setting we found that legitimately permits partial, unmerged group output —distributed_group_by_no_merge— is 0 (default), and this is not a distributed query.Related
Likely the same family as #109216 (
partial_merge+optimize_distinct_in_order→ wrongDISTINCTresults, sort-order property claimed across a join that does not preserve it). Same control setting, same "assume a stream property the join does not actually preserve" shape, different join algorithm and different direction (thereDISTINCTabove the join, here below it).Minimal reproducible example
Attached:
dim_part1..6.tsv.gz+facts.tsv.gzand the schema/load/query script below.Schema
Load
Load the six
dimfiles in order — each becomes exactly one part, and the resulting part-size skew (four ~560–590k-row parts, two tiny ones) is load-bearing:Row counts after loading:
dim= 2,288,189 (collapses to 581,668 distinct(grp,k1,k2)triples underFINAL+DISTINCT),facts= 450,338 rows / 225,473 distinct(k1,k2)pairs.Query, run repeatedly
Under pure server defaults (nothing set), this split 5/20, then 1/20 in two independent 20-run samples on a freshly loaded service — no dependency on the originating instance. Forcing
join_algorithm='parallel_hash', max_threads=4raised it to 17/20 and 19/20 in two samples on that same fresh service.Correct:
Incorrect (same query, same data, same settings —
code_2appears twice, split):5133 + 261092 = 266225— the correct total, matching the sum-preservation pattern from the original observation above.We're happy to run any candidate diagnostic query against this same reloaded, anonymized dataset and report back.