Skip to content

Arrow scan: filter on a dictionary column with string_view values is pushed down and fails #663

Closed
duckdb/duckdb
#27319

Description

@willfrey

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> &params,
+                                  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

  1. added a commit that references this issue on Oct 16, 2026
    0419942
  2. evertlammerts commented on Oct 19, 2026

    @evertlammerts
    Member

    Thanks This should be fixed by #693 and duckdb/duckdb#27319. The latter will be part of the v2.0.1 bugfix release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions