Repository: duckdb/duckdb (2026-07-29T22:00:37Z)

Issue:
Fixed-size ARRAY extract filters miss row-group pruning
### What happens?

See repro for details.

### To Reproduce

```sql
memory D CREATE TABLE scalar_small AS
         SELECT i::INTEGER AS x FROM range(0, 1000000) tbl(i);
memory D 
memory D CREATE TABLE array_small AS
         SELECT ([i::INTEGER, (i+1)::INTEGER, (i+2)::INTEGER]::INTEGER[3]) AS a
         FROM range(0, 1000000) tbl(i);
memory D 
memory D EXPLAIN ANALYZE SELECT count(*) FROM scalar_small WHERE x BETWEEN 500000 AND 500010;
╭─ Summary ───────────╮
│ Total Time: 0.0006s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ────────────────────╮
│ Aggregates: count_star()                 │
│ 1 row                                0µs │
╰─────────────────────┒┎───────────────────╯
╭─ Table Scan ────────┚┖───────────────────╮
│ Table: memory.main.scalar_small          │
│ Type: Sequential Scan                    │
│ Filters: (x >= 500000) AND (x <= 500010) │
│ Row Groups Scanned: 1 / 9                │
│ 11 rows                              0µs │
╰──────────────────────────────────────────╯

-- doesn't do any pruning
memory D EXPLAIN ANALYZE SELECT count(*) FROM array_small WHERE a[1] BETWEEN 500000 AND 500010;
╭─ Summary ───────────╮
│ Total Time: 0.0043s │
╰─────────────────────╯
╭─ Ungrouped Aggregate ────────────────────────────╮
│ 1 row                                        0µs │
╰─────────────────────────┒┎───────────────────────╯
╭─ Table Scan ────────────┚┖───────────────────────╮
│ Table: memory.main.array_small                   │
│ Type: Sequential Scan                            │
│ Filters:                                         │
│ array_extract(CAST(a AS INTEGER[]), 1) BETWEEN   │
│ 500000 AND 500010                                │
│ Row Groups Scanned: 9 / 9                        │
│ 11 rows                                   20.0ms │
╰──────────────────────────────────────────────────╯
```

### OS:

macos

### DuckDB Version:

b31d0c62f91a8d3e3a43a1c87c608cba2390f95a

### 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/src/optimizer/statistics/expression/propagate_cast.cpp b/src/optimizer/statistics/expression/propagate_cast.cpp
index 90f189239316..16aba989f2ba 100644
--- a/src/optimizer/statistics/expression/propagate_cast.cpp
+++ b/src/optimizer/statistics/expression/propagate_cast.cpp
@@ -1,5 +1,7 @@
 #include "duckdb/optimizer/statistics_propagator.hpp"
 #include "duckdb/planner/expression/bound_cast_expression.hpp"
+#include "duckdb/storage/statistics/array_stats.hpp"
+#include "duckdb/storage/statistics/list_stats.hpp"
 #include "duckdb/storage/statistics/struct_stats.hpp"
 #include "duckdb/storage/statistics/variant_stats.hpp"
 
@@ -218,6 +220,23 @@ static unique_ptr<BaseStatistics> StatisticsPropagateVariant(const BaseStatistic
 	return StatisticsPropagator::TryPropagateCast(typed_stats, structured_type, target);
 }
 
+static unique_ptr<BaseStatistics> StatisticsPropagateArrayToList(const BaseStatistics &input, const LogicalType &source,
+                                                                 const LogicalType &target) {
+	D_ASSERT(source.id() == LogicalTypeId::ARRAY);
+	D_ASSERT(target.id() == LogicalTypeId::LIST);
+
+	auto &source_child_type = ArrayType::GetChildType(source);
+	auto &target_child_type = ListType::GetChildType(target);
+	if (source_child_type != target_child_type || input.GetStatsType() != StatisticsType::ARRAY_STATS) {
+		return nullptr;
+	}
+
+	auto result = ListStats::CreateEmpty(target);
+	result.CopyBase(input);
+	ListStats::GetChildStats(result).Copy(ArrayStats::GetChildStats(input));
+	return result.ToUnique();
+}
+
 unique_ptr<BaseStatistics> StatisticsPropagator::TryPropagateCast(const BaseStatistics &stats,
                                                                   const LogicalType &source,
                                                                   const LogicalType &target) {
@@ -233,6 +252,9 @@ unique_ptr<BaseStatistics> StatisticsPropagator::TryPropagateCast(const BaseStat
 		// type set and null-ness are unchanged: propagate the statistics as-is.
 		return stats.Copy().ToUnique();
 	}
+	if (source.id() == LogicalTypeId::ARRAY && target.id() == LogicalTypeId::LIST) {
+		return StatisticsPropagateArrayToList(stats, source, target);
+	}
 	if (!CanPropagateCast(source, target)) {
 		return nullptr;
 	}


Test patch:
diff --git a/test/sql/optimizer/array_extract_row_group_pruning.test b/test/sql/optimizer/array_extract_row_group_pruning.test
new file mode 100644
index 000000000000..902dc6d41c84
--- /dev/null
+++ b/test/sql/optimizer/array_extract_row_group_pruning.test
@@ -0,0 +1,61 @@
+# name: test/sql/optimizer/array_extract_row_group_pruning.test
+# description: Fixed-size array extracts use child statistics for row-group pruning
+# group: [optimizer]
+
+statement ok
+ATTACH '{TEST_DIR}/array_extract_row_group_pruning.duckdb' AS db (ROW_GROUP_SIZE 2048);
+
+statement ok
+USE db;
+
+statement ok
+CREATE TABLE arrays AS
+SELECT [i::INTEGER, (i + 1)::INTEGER, (i + 2)::INTEGER]::INTEGER[3] AS a
+FROM range(6144) tbl(i);
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM arrays WHERE a[1] BETWEEN 2900 AND 3100;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM arrays WHERE a[1] BETWEEN 2900 AND 3100;
+----
+201
+
+query II
+EXPLAIN ANALYZE SELECT count(*) FROM arrays WHERE a[-1] BETWEEN 2902 AND 3102;
+----
+analyzed_plan	<REGEX>:.*Row Groups Scanned: 1 / 3.*
+
+query I
+SELECT count(*) FROM arrays WHERE a[-1] BETWEEN 2902 AND 3102;
+----
+201
+
+# Out-of-range extracts remain NULL
+query I
+SELECT count(*) FROM arrays WHERE a[4] IS NULL;
+----
+6144
+
+statement ok
+CREATE TABLE nullable_arrays(a INTEGER[3]);
+
+statement ok
+INSERT INTO nullable_arrays VALUES (NULL), ([1, NULL, 3]), ([4, 5, 6]);
+
+query I
+SELECT count(*) FROM nullable_arrays WHERE a[1] IS NULL;
+----
+1
+
+query I
+SELECT count(*) FROM nullable_arrays WHERE a[2] IS NULL;
+----
+2
+
+query I
+SELECT count(*) FROM nullable_arrays WHERE a[4] IS NULL;
+----
+3