Skip to content

Zero-copy Arrow interop (to_arrow / __arrow_c_stream__ / from_arrow)#596

Open
wudidapaopao wants to merge 4 commits into
chdb-io:mainfrom
wudidapaopao:support_arrow_c_stream
Open

Zero-copy Arrow interop (to_arrow / __arrow_c_stream__ / from_arrow)#596
wudidapaopao wants to merge 4 commits into
chdb-io:mainfrom
wudidapaopao:support_arrow_c_stream

Conversation

@wudidapaopao

@wudidapaopao wudidapaopao commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Adds Arrow interop to DataStore, built on the Arrow PyCapsule interface (the pandas 3.0 standard). Lets DataStore results flow to Polars / DuckDB / pandas 3.0 without a pandas round-trip.

What

  • to_arrow(types='native'|'pandas') — a fully pushed-down pipeline returns a pyarrow.Table straight from chDB's ArrowTable output (no pandas round-trip), keeping ClickHouse-native types (UInt64uint64, nullable ints stay nullable). Falls back to converting the pandas result for pipelines that need pandas-side ops.
  • __arrow_c_stream__()pa.table(ds), pl.from_arrow(ds), duckdb.sql('... FROM ds') and pd.DataFrame.from_arrow(ds) all work with no glue code.
  • from_arrow(data) — ingest any PyCapsule producer (pyarrow Table/RecordBatch/reader, Polars, pandas 3.x, DuckDB, another DataStore).
  • Adds Connection.query_arrow / Executor.query_arrow.

Notes

  • Additive only; to_df() and existing behavior unchanged.
  • Arrow has no row index, so group keys from groupby(as_index=True) appear as regular columns.
  • Ingest goes through pyarrow (like pandas 3.0's own from_arrow), so it is not zero-copy on import; the zero-copy/native-typed path is on export.

Tests

datastore/tests/test_arrow_interop.py — 23 tests: native types, filter/select/groupby pushdown, interop (pyarrow/Polars/DuckDB/pandas), ingest, round-trips. Passing locally.

Closes #595

Note

Add zero-copy Arrow interop (to_arrow, from_arrow, __arrow_c_stream__) to DataStore

  • Adds DataStore.to_arrow(types='native'|'pandas') which returns a pyarrow.Table; in 'native' mode it pushes down a single SQL query to chDB's Arrow output format, falling back to pandas conversion when pushdown is unavailable.
  • Adds DataStore.from_arrow(data) classmethod accepting Arrow PyCapsule objects, pyarrow.Table/RecordBatch/RecordBatchReader, and pandas/Polars DataFrames.
  • Implements DataStore.__arrow_c_stream__ so DataStore objects are directly consumable by PyArrow, Polars, DuckDB, and pandas 3.x without intermediate copies.
  • Adds Connection.query_arrow and Executor.query_arrow to execute SQL and return a pyarrow.Table via chDB's ArrowTable output format.
  • A new test module covers round-trips, native type preservation (decimal, list, map, struct, binary), empty/null columns, requested_schema casting, and error handling.

Macroscope summarized 54b97e9.

…__ / from_arrow)

Hand DataStore results to Polars/DuckDB/pandas 3.0 without a pandas round-trip.

- to_arrow(types='native'|'pandas'): fully pushed-down queries return a
  pyarrow.Table straight from chDB's ArrowTable output, keeping ClickHouse-native
  Arrow types (UInt64->uint64, nullable ints stay nullable); falls back to the
  pandas result for pipelines that need pandas-side ops.
- __arrow_c_stream__(): Arrow PyCapsule interface (the pandas 3.0 interchange
  standard) so pa.table(ds) / pl.from_arrow(ds) / duckdb.sql('... FROM ds') /
  pd.DataFrame.from_arrow(ds) work with no glue code.
- from_arrow(): ingest any PyCapsule producer (pyarrow Table/RecordBatch/reader,
  Polars, pandas 3.x, DuckDB, another DataStore) via faithful ArrowDtype.

Adds Connection.query_arrow / Executor.query_arrow and a test suite covering
native types, pushdown, interop, ingest, and round-trips.

Resolves chdb-io#595

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@wudidapaopao wudidapaopao changed the title feat(datastore): zero-copy Arrow interop (to_arrow / __arrow_c_stream__ / from_arrow) Zero-copy Arrow interop (to_arrow / __arrow_c_stream__ / from_arrow) Jul 1, 2026
wudidapaopao and others added 2 commits July 1, 2026 09:50
Add TestPandas3xIngestDtypes / TestPandas3xConsumer / TestNativeArrowTypes:

- from_arrow() ingest of pandas 3.x dtypes: nullable Int64/boolean/Float64,
  the pandas 3.0 default string dtype, ArrowDtype, datetime, tz-aware datetime,
  Categorical (values + null positions preserved).
- pd.DataFrame.from_arrow(ds) (the pandas 3.0 API): uint64 stays unsigned,
  null positions preserved.
- Native export types with no pandas coercion: decimal128, list, computed
  columns via assign, empty tables, all-null columns.

36 tests total, all passing.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… coverage

CI runs the suite under both pandas 2.x and 3.x, so:

- from_arrow(): accept a plain pandas.DataFrame directly (via from_df). pandas
  2.x DataFrames don't implement the Arrow PyCapsule interface, so they'd
  otherwise fall through to TypeError.
- Gate the two pandas-3.0-only tests (pd.DataFrame.from_arrow constructor and
  the default string dtype) behind PANDAS_3_PLUS.

New edge-case coverage (all pandas-version agnostic — type fidelity via the
native from_file path; streaming/requested_schema via pyarrow):

- Nested/binary types: Map, struct (Tuple), fixed_size_binary, awkward column
  names (spaces / unicode).
- Streaming: multi-batch RecordBatchReader ingest; DataStore consumed via
  pa.RecordBatchReader.from_stream; __arrow_c_stream__ requested_schema
  (compatible cast honored, incompatible cast raises).
- Ibis interop (importorskip).

Verified: pandas 3.0.3 (py3.14) and pandas 2.3.3 (py3.12) both green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@wudidapaopao

Copy link
Copy Markdown
Contributor Author

@chibugai, review this PR.

Comment thread datastore/connection.py Outdated
Comment on lines +175 to +176
self._log_query(sql, "Connection", "ArrowTable")
return self._conn.query(sql, "ArrowTable")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unlike execute and query_df, this method does not wrap the _conn.query() call in a try/except. On failure, raw chDB exceptions propagate untranslated instead of being converted into a friendly ExecutionError. This breaks the established error-handling pattern and exposes low-level errors to callers. Consider wrapping the call consistently:

try:
    return self._conn.query(sql, "ArrowTable")
except Exception as e:
    self._logger.error("[chDB] Arrow query failed: %s", e)
    friendly_msg = translate_remote_error(e)
    raise ExecutionError(f"Query execution failed: {friendly_msg}\nSQL: {sql}")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in 54b97e9. query_arrow now wraps the _conn.query() call in the same try/except as execute(), logging and translating failures via translate_remote_error into a friendly ExecutionError instead of leaking the raw chDB exception. Added a regression test (TestQueryArrowErrorHandling) that asserts an invalid query raises ExecutionError.

Comment thread datastore/core.py Outdated
if table is not None:
return table

return pa.Table.from_pandas(self._execute(), preserve_index=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback uses preserve_index=False, which drops the index instead of converting it to columns. This contradicts the docstring Note ("group keys from groupby(as_index=True) appears as regular columns here") and silently loses data: a groupby(as_index=True).agg(...) pipeline routed through this fallback (e.g. a DataFrame source, or any pandas-side op, or types="pandas") returns a Table missing the group-key columns. This also diverges from the native path (_try_native_arrow), where group keys stay as columns. Consider resetting the index into columns before converting, e.g.:

df = self._execute()
if df.index.name is not None or isinstance(df.index, pd.MultiIndex) or df.index.names != [None]:
    df = df.reset_index()
return pa.Table.from_pandas(df, preserve_index=False)

(or use preserve_index=True only for named/group indexes) so the fallback matches both the documented behavior and the native path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, this was a real data-loss bug — fixed in 54b97e9. The fallback now resets a meaningful index into columns before conversion:

df = self._execute()
if df.index.names != [None]:
    df = df.reset_index()
return pa.Table.from_pandas(df, preserve_index=False)

So group keys from groupby(as_index=True) (and any named index / MultiIndex) are preserved as columns, matching both the native path and the docstring Note. A plain unnamed RangeIndex is still left out (no spurious index column). Confirmed the repro (from_df(...).groupby("city").agg(...) previously returned only ["score"], now ["city", "score"]). Added regressions for the from_df-groupby, types="pandas"-groupby, and named-index cases.

…in Arrow fallback

Review feedback on PR chdb-io#596:

- Connection.query_arrow now wraps chDB errors in ExecutionError via
  translate_remote_error, matching execute()/query_df() instead of leaking raw
  low-level exceptions.
- to_arrow() pandas fallback no longer drops the row index: a meaningful index
  (named index, or group keys from groupby(as_index=True), or a MultiIndex) is
  reset into columns before conversion. Previously preserve_index=False silently
  dropped groupby group-key columns on the fallback path (DataFrame source /
  pandas-side ops / types="pandas"), diverging from the native path and losing data.

Regression tests added for both; verified on pandas 3.0.3 (py3.14) and 2.3.3 (py3.12).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DataStore: zero-copy Arrow interop (to_arrow / __arrow_c_stream__ / from_arrow)

2 participants