Repository navigation
Arrow scan: filter on a dictionary column with string_view values is pushed down and fails #663
Description
What happens?
A filter on a dictionary-encoded Arrow column whose values are string_view (dictionary<values=string_view, indices=int8>) raises duckdb.Error carrying pyarrow's ArrowNotImplementedError: Function 'equal' has no kernel matching input types (string_view, string). The same filter on a plain string_view column works, because filters on view types are not pushed down (duckdb/duckdb#20165, for #187), and since duckdb/duckdb#20703 (for #227) a view column turns off pushdown for the whole table. A filter on another column of the same table works too.
Likely cause: CanPushdown(const ArrowType &type) in duckdb/duckdb's src/function/table/arrow.cpp judges a column by type.GetDuckType(), which for a dictionary-encoded column is the index type. An integer index type is always pushable, so the filter is pushed down to pyarrow even though it compares the dictionary's values, and pyarrow can't evaluate the comparison. HasViewType also judges by GetDuckType() and so never sees a dictionary's values; that is right for pyarrow, whose array_filter filters a dictionary column by its indices.
Suggested fix
The change is in duckdb/duckdb, where #20165 and #20703 also landed. Judge a dictionary-encoded column by its value type:
static bool CanPushdown(const ArrowType &type) {
+ if (type.HasDictionary()) {
+ // a filter on a dictionary-encoded column compares the dictionary's values, not its indices
+ return CanPushdown(type.GetDictionary());
+ }
auto duck_type = type.GetDuckType();With this change, CanPushdown refuses dictionary<string_view>. Dictionary columns whose value types CanPushdown already rejects also stop being pushed down. dictionary<utf8> is still pushed. A test case for test/arrow/arrow_filter_pushdown.cpp is below. It checks the EXPLAIN plan, and its string_view section fails without the change and passes with it. A filter on another column of that table is still pushed. The [arrow] tests pass.
Test
--- a/test/arrow/arrow_filter_pushdown.cpp
+++ b/test/arrow/arrow_filter_pushdown.cpp
@@ -1,6 +1,8 @@
#include "catch.hpp"
#include "arrow/arrow_test_helper.hpp"
+#include "duckdb/common/adbc/single_batch_array_stream.hpp"
+#include "duckdb/common/types/arrow_string_view_type.hpp"
using namespace duckdb;
@@ -23,8 +25,8 @@
}
// Helper: get the EXPLAIN output for an arrow_scan with a filter
-static string GetExplainForFilter(Connection &con, ArrowTestFactory &factory, const string &filter_expr) {
- const auto params = ArrowTestHelper::ConstructArrowScan(factory);
+static string GetExplainForFilter(Connection &con, const shared_ptr<ArrowScanFactory> ¶ms,
+ const string &filter_expr) {
const auto rel = con.TableFunction("arrow_scan", {}, {}, params)->Filter(filter_expr);
// render operator names raw/upper-case so the string checks below (ARROW_SCAN, FILTER) match
REQUIRE(!con.Query("SET profiling_renderer_settings = MAP {'operator_casing': 'upper'}")->HasError());
@@ -34,6 +36,10 @@
return mat.Collection().GetValue(1, 0).ToString();
}
+static string GetExplainForFilter(Connection &con, ArrowTestFactory &factory, const string &filter_expr) {
+ return GetExplainForFilter(con, ArrowTestHelper::ConstructArrowScan(factory), filter_expr);
+}
+
// Helper: check for a standalone FILTER operator node in the explain output
static bool StandaloneFilter(const std::string &explain_str) {
// Look for FILTER as a standalone operator node name (not "Filters:" which is a scan attribute)
@@ -107,3 +113,142 @@
REQUIRE(!FilterInScan(explain_str));
}
}
+
+namespace arrow_filter_pushdown_dict_test {
+
+// Stack-allocated Arrow structs need a no-op release.
+void ReleaseSchema(ArrowSchema *s) {
+ s->release = nullptr;
+}
+void ReleaseArray(ArrowArray *a) {
+ a->release = nullptr;
+}
+
+const int32_t X_VALUES[5] = {0, 1, 2, 3, 4};
+const int8_t D_INDICES[5] = {0, 1, 2, 0, 1};
+const int32_t UTF8_OFFSETS[4] = {0, 3, 8, 12};
+const char UTF8_DATA[] = "redgreenblue";
+// a string view array with no variadic data buffers still ends with the (empty) buffer-sizes buffer
+const int64_t NO_BUFFER_SIZES[1] = {0};
+
+enum class DictValues { UTF8, STRING_VIEW };
+
+// One batch of two columns: x INTEGER 0..4, and d holding "red", "green", "blue", "red", "green" as a dictionary
+// with int8 indices whose values are utf8 or string view, as a single-batch stream for binding the scan.
+struct DictTable {
+ explicit DictTable(DictValues values)
+ : views {arrow_string_view_t(3, "red"), arrow_string_view_t(5, "green"), arrow_string_view_t(4, "blue")} {
+ dict_buffers[0] = nullptr;
+ switch (values) {
+ case DictValues::UTF8:
+ dict_schema.format = "u";
+ dict_buffers[1] = UTF8_OFFSETS;
+ dict_buffers[2] = UTF8_DATA;
+ break;
+ case DictValues::STRING_VIEW:
+ // every value fits inline, so the views buffer is followed only by the buffer sizes
+ dict_schema.format = "vu";
+ dict_buffers[1] = views;
+ dict_buffers[2] = NO_BUFFER_SIZES;
+ break;
+ }
+ dict_schema.release = ReleaseSchema;
+ dict_array.length = 3;
+ dict_array.n_buffers = 3;
+ dict_array.buffers = dict_buffers;
+ dict_array.release = ReleaseArray;
+
+ x_schema.format = "i";
+ x_schema.name = "x";
+ x_schema.release = ReleaseSchema;
+ x_buffers[0] = nullptr;
+ x_buffers[1] = X_VALUES;
+ x_array.length = 5;
+ x_array.n_buffers = 2;
+ x_array.buffers = x_buffers;
+ x_array.release = ReleaseArray;
+
+ d_schema.format = "c";
+ d_schema.name = "d";
+ d_schema.dictionary = &dict_schema;
+ d_schema.release = ReleaseSchema;
+ d_buffers[0] = nullptr;
+ d_buffers[1] = D_INDICES;
+ d_array.length = 5;
+ d_array.n_buffers = 2;
+ d_array.buffers = d_buffers;
+ d_array.dictionary = &dict_array;
+ d_array.release = ReleaseArray;
+
+ schema_children[0] = &x_schema;
+ schema_children[1] = &d_schema;
+ record_schema.format = "+s";
+ record_schema.n_children = 2;
+ record_schema.children = schema_children;
+ record_schema.release = ReleaseSchema;
+ array_children[0] = &x_array;
+ array_children[1] = &d_array;
+ record_array.length = 5;
+ record_array.n_buffers = 1;
+ record_array.buffers = record_buffers;
+ record_array.n_children = 2;
+ record_array.children = array_children;
+ record_array.release = ReleaseArray;
+ AdbcError err {};
+ REQUIRE(duckdb_adbc::BatchToArrayStream(&record_array, &record_schema, &stream, &err) == ADBC_STATUS_OK);
+ }
+ DictTable(const DictTable &) = delete;
+ ~DictTable() {
+ // an EXPLAIN only binds the scan, so the stream is still ours to release
+ if (stream.release) {
+ stream.release(&stream);
+ }
+ }
+
+ arrow_string_view_t views[3];
+ const void *dict_buffers[3];
+ const void *x_buffers[2];
+ const void *d_buffers[2];
+ const void *record_buffers[1] = {nullptr};
+ ArrowSchema *schema_children[2];
+ ArrowArray *array_children[2];
+ ArrowSchema dict_schema {};
+ ArrowSchema x_schema {};
+ ArrowSchema d_schema {};
+ ArrowSchema record_schema {};
+ ArrowArray dict_array {};
+ ArrowArray x_array {};
+ ArrowArray d_array {};
+ ArrowArray record_array {};
+ ArrowArrayStream stream {};
+};
+
+} // namespace arrow_filter_pushdown_dict_test
+
+TEST_CASE("Arrow filter pushdown - dictionary columns are judged by their value type", "[arrow]") {
+ using namespace arrow_filter_pushdown_dict_test;
+ DuckDB db;
+ Connection con(db);
+
+ SECTION("Filter on a dictionary column with string view values: filter above scan (not pushed)") {
+ DictTable table(DictValues::STRING_VIEW);
+ auto explain_str = GetExplainForFilter(con, ArrowTestHelper::ConstructArrowScan(table.stream), "d = 'red'");
+ REQUIRE(StandaloneFilter(explain_str));
+ REQUIRE(!FilterInScan(explain_str));
+ }
+
+ SECTION("Filter on another column of a table with a string view dictionary: filter pushed into scan") {
+ // pyarrow filters a dictionary column by its indices and never reads the values
+ DictTable table(DictValues::STRING_VIEW);
+ auto explain_str = GetExplainForFilter(con, ArrowTestHelper::ConstructArrowScan(table.stream), "x > 2");
+ REQUIRE(!StandaloneFilter(explain_str));
+ REQUIRE(FilterInScan(explain_str));
+ }
+
+ SECTION("Filter on a dictionary column with utf8 values: filter pushed into scan") {
+ DictTable table(DictValues::UTF8);
+ auto explain_str = GetExplainForFilter(con, ArrowTestHelper::ConstructArrowScan(table.stream), "d = 'red'");
+ REQUIRE(!StandaloneFilter(explain_str));
+ REQUIRE(FilterInScan(explain_str));
+ }
+}To Reproduce
import duckdb
import pyarrow as pa
import pyarrow.dataset as ds
d = pa.DictionaryArray.from_arrays(
pa.array([0, 1, 0], pa.int8()), pa.array(["red", "blue"], pa.string_view())
)
c = duckdb.connect()
c.register("t", ds.dataset(pa.table({"x": [1, 2, 3], "d": d})))
print(c.sql("SELECT x FROM t WHERE x > 1").fetchall()) # [(2,), (3,)]
print(c.sql("SELECT x FROM t WHERE d = 'red'").fetchall()) # duckdb.Error: ArrowNotImplementedError: ...Expected: [(1,), (3,)] from the second query.
The same failure occurs when t is the pa.Table itself, a pa.RecordBatchReader or a pyarrow.dataset.Scanner, and with duckdb.from_arrow(...): any scan whose pushed filters pyarrow evaluates.
OS:
macOS 26.5.1, arm64
DuckDB Package Version:
1.5.6 and 2.0.0.dev2610011535
Python Version:
3.14.7 (pyarrow 26.0.0)
Full Name:
Will Frey
Affiliation:
dataplor
What is the latest build you tested with? If possible, we recommend testing with the latest nightly build.
I have tested with a nightly build
Did you include all relevant data sets for reproducing the issue?
Not applicable - the reproduction does not require a data set
Did you include all code required to reproduce the issue?
- Yes, I have
Did you include all relevant configuration to reproduce the issue?
- Yes, I have
Activity
[arrow] Fix pushdown errors (duckdb#27319)
evertlammerts commentedon Oct 19, 2026
Thanks This should be fixed by #693 and duckdb/duckdb#27319. The latter will be part of the v2.0.1 bugfix release.