Repository: duckdb/duckdb (2026-07-29T08:45:37Z)

Issue:
Parquet struct type doesn't leverage bloom filter to prune row groups
### What happens?

See repro for details.

### To Reproduce

```sql
memory D COPY (
             SELECT
                 {
                     'level_1': {
                         'level_2': {
                             'value': ((i % 100) * 2)::INTEGER
                         }
                     }
                 } AS struct_value,
                 i::BIGINT AS payload
             FROM range(20480) t(i)
         )
         TO '/tmp/bloom_struct.parquet' (
             FORMAT PARQUET,
             ROW_GROUP_SIZE 2048,
             DICTIONARY_SIZE_LIMIT 1000,
             WRITE_BLOOM_FILTER TRUE
         );
memory D EXPLAIN ANALYZE
         SELECT struct_value, payload
         FROM read_parquet('/tmp/bloom_struct.parquet')
         WHERE struct_value.level_1.level_2.value = 101;
╭─ Summary ───────────╮
│ Total Time: 0.0018s │
│ Data Read: 107.0 kB │
╰─────────────────────╯
╭─ Table Scan ─────────────────────────────────────╮
│ Function: READ_PARQUET                           │
│ Projections: struct_value, payload               │
│ Filters:                                         │
│ struct_extract(struct_extract(struct_extract(st… │
│ 'level_1'), 'level_2'), 'value') = 101           │
│ Total Files Read: 1                              │
│ Filename(s): /tmp/bloom_struct.parquet           │
│ Row Groups Scanned: 10 / 10                      │
│ 0 rows                                       0µs │
╰──────────────────────────────────────────────────╯
```

### OS:

macos

### DuckDB Version:

e5d6787a684fd90084704edbc412049760304a8f

### DuckDB Client:

SQL

### Hardware:

_No response_

### Full Name:

dentiny

### Affiliation:

N/A

### Did you include all relevant configuration (e.g., CPU architecture, Linux distribution) to reproduce the issue?

- [x] Yes, I have

### Did you include all code required to reproduce the issue?

- [x] Yes, I have

### Did you include all relevant data sets for reproducing the issue?

Yes


Discussion:

Patch:
diff --git a/extension/parquet/parquet_reader.cpp b/extension/parquet/parquet_reader.cpp
index e4afefc8dd90..ea47aa207f83 100644
--- a/extension/parquet/parquet_reader.cpp
+++ b/extension/parquet/parquet_reader.cpp
@@ -48,6 +48,7 @@
 #include "duckdb/common/types/data_chunk.hpp"
 #include "duckdb/common/types/vector.hpp"
 #include "duckdb/common/vector_size.hpp"
+#include "duckdb/function/scalar/struct_utils.hpp"
 #include "duckdb/logging/log_type.hpp"
 #include "duckdb/logging/logger.hpp"
 #include "duckdb/main/client_context.hpp"
@@ -1483,8 +1484,8 @@ static FilterPropagateResult CheckParquetFloatFilter(ClientContext &context, Col
 	return FilterPropagateResult::NO_PRUNING_POSSIBLE;
 }
 
-static bool TryGetListBloomFilterLeaf(ColumnReader &column_reader, const Expression &expr,
-                                      optional_ptr<ColumnReader> &leaf_reader) {
+static bool TryGetNestedBloomFilterLeaf(ColumnReader &column_reader, const Expression &expr,
+                                        optional_ptr<ColumnReader> &leaf_reader) {
 	if (expr.GetExpressionClass() == ExpressionClass::BOUND_REF) {
 		if (expr.Cast<BoundReferenceExpression>().Index() != 0) {
 			return false;
@@ -1498,14 +1499,31 @@ static bool TryGetListBloomFilterLeaf(ColumnReader &column_reader, const Express
 
 	auto &function = expr.Cast<BoundFunctionExpression>();
 	if (function.GetChildren().empty() ||
-	    !TryGetListBloomFilterLeaf(column_reader, *function.GetChildren()[0], leaf_reader)) {
+	    !TryGetNestedBloomFilterLeaf(column_reader, *function.GetChildren()[0], leaf_reader)) {
 		return false;
 	}
+
+	// Handle LIST type.
 	if (leaf_reader->Type().id() == LogicalTypeId::LIST &&
 	    (function.Function().GetName() == "list_extract" || function.Function().GetName() == "array_extract")) {
 		leaf_reader = &leaf_reader->Cast<ListColumnReader>().GetChildReader();
 		return true;
 	}
+
+	// Handle STRUCT type.
+	if (leaf_reader->Type().id() == LogicalTypeId::STRUCT) {
+		idx_t child_idx;
+		if (!TryGetStructExtractChildIndex(function, child_idx)) {
+			return false;
+		}
+		auto &struct_reader = leaf_reader->Cast<StructColumnReader>();
+		if (child_idx >= struct_reader.child_readers.size() || !struct_reader.child_readers[child_idx]) {
+			return false;
+		}
+		leaf_reader = struct_reader.child_readers[child_idx].get();
+		return true;
+	}
+
 	return false;
 }
 
@@ -1553,7 +1571,7 @@ static bool TryGetBloomFilterLeaf(ColumnReader &column_reader, const TableFilter
 		return false;
 	}
 
-	if (!TryGetListBloomFilterLeaf(column_reader, *column_expr, leaf_reader) ||
+	if (!TryGetNestedBloomFilterLeaf(column_reader, *column_expr, leaf_reader) ||
 	    leaf_reader->Type() != constant->GetValue().type()) {
 		return false;
 	}


Test patch:
diff --git a/test/sql/copy/parquet/bloom_filters.test b/test/sql/copy/parquet/bloom_filters.test
index 4194ff825947..c81c98967ed9 100644
--- a/test/sql/copy/parquet/bloom_filters.test
+++ b/test/sql/copy/parquet/bloom_filters.test
@@ -111,6 +111,9 @@ COPY (
     SELECT
         [v, v + 2] AS list_value,
         [[[v, v + 2]]] AS deep_list,
+        {
+            'level_1': {'level_2': {'value': v}}
+        } AS struct_value,
         i::BIGINT AS payload
     FROM (
         SELECT i, ((i % 100) * 2)::INTEGER AS v
@@ -184,6 +187,22 @@ WHERE deep_list[1][1][1] = 101;
 ----
 analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
 
+# Deeply nested structs
+query I
+SELECT count(*)
+FROM read_parquet('{TEST_DIR}/bloom_list.parquet')
+WHERE struct_value.level_1.level_2.value = 101;
+----
+0
+
+query II
+EXPLAIN ANALYZE
+SELECT struct_value, payload
+FROM read_parquet('{TEST_DIR}/bloom_list.parquet')
+WHERE struct_value.level_1.level_2.value = 101;
+----
+analyzed_plan	<REGEX>:(?s).*Row Groups Scanned: 0 / 10.*
+
 # A possible deep-list leaf match must retain its row groups
 query I
 SELECT count(deep_list)