Skip to content

chore: Isolate interchange-level support#3721

Open
dangotbanned wants to merge 45 commits into
chore/issue-2932-rm-levelfrom
chore/issue-2932-rm-level-break
Open

chore: Isolate interchange-level support#3721
dangotbanned wants to merge 45 commits into
chore/issue-2932-rm-levelfrom
chore/issue-2932-rm-level-break

Conversation

@dangotbanned

@dangotbanned dangotbanned commented Jun 27, 2026

Copy link
Copy Markdown
Member

Description

This PR started as an experiment and snowballed into an informed refactor™.

Note

TL;DR: In (#3558 (comment)) I had no clue what interchange meant
It now has clear boundaries, based on what people actually use
All related tests can be run with uv run pytest tests/interchange

Related issues

Experiment

To find out what interchange really means:

  • I removed it
  • Looked at what broke downstream
  • Gradually added things until both our tests and downstream were green

Break interchange (e745e37)

  • Removed it entirely from main, v2
  • Raising when requested for v1
  • xfail 21 tests

Identify downstream breakage

Found out we have coverage from 3 downstream packages for eager_or_interchange_only=True

Identify downstream usage

Altair

Marimo

Plotly

Summary

  • Altair and Plotly could benefit from something like from_arrow(DataFrameLike, backend="pyarrow")
  • Interchange is simply a stepping stone to pyarrow

Re-introduce interchange

Possible follow-ups

Found a good point to stop, but still had a few ideas that could follow.

I'm not in any rush to do this and would be happy to lock interchange up and never look back 😄

Docs

Be explicit about what is supported for ibis and duckdb (and suggest using v2 instead)

Feat

Almost all use cases I found for interchange support would be solved by a new v1 function like:

def from_interchange(
    frame: DataFrameLike, *, backend: IntoBackend[EagerAllowed] = "pyarrow"
) -> nw_v1.DataFrame[pa.Table | pl.DataFrame | pd.DataFrame]: ...

Where the default is "pyarrow" because Altair, Plotly, and Vegafusion already require it for interchange support.

Tests

  • Deduplicate the test suite
    • Most ibis tests are 80% setup code
  • Test everything against: ibis, duckdb, pl.DataFrame.__dataframe__, custom
  • Separate tests for columns, collect_schema, to_native, get_column
    • They're all supported, but usually added onto some other test (or indirectly before this PR)

CI

Following #3629, we no longer test Vegafusion in downstream ci 1.

An alternative to reintroducing that workflow is to run Altair again, but with Vegafusion enabled.
That should get us some more coverage for v1, without needing to compile from source

Footnotes

  1. Not sure why, guessing speed?

Going scorched-earth to find out who's using it downstream

Part 1 of (#3558 (comment))
The error was checking a flag that we didn't set?
@MarcoGorelli

Copy link
Copy Markdown
Member

thanks for looking into this

just noting that whatever solution we go with shouldn't require downstream libraries (marimo / altair / ....) to change anything on their side. i'm fairly confident that it should be possible to remove this from v2/main without affecting v1 usage at all

@dangotbanned

dangotbanned commented Jun 28, 2026

Copy link
Copy Markdown
Member Author

just noting that whatever solution we go with shouldn't require downstream libraries (marimo / altair / ....) to change anything on their side

100% agreed @MarcoGorelli !

My plan now is to:

  1. Get all downstream green (with the smallest interchange footprint possible)
    i. Altair & Plotly are all good with "just" interchange
    ii. Marimo is a WIP
  2. Do a github search dive to find other users of interchange
  3. Consider extending our downstream ci testing if I find someone using more than marimo

I'm still kinda in a fact-finding stage, but feel like I understand interchange better than yesterday 😄

It was raising in `Series.from_iterable` before, because of `list[bool]`
- Still quite a mess
- No more dataframe in `IbisLazyFrame`
- Probably too much typing
Not sure `to_native` was ever supposed to work on interchange,
but they're testing it
Comment thread src/narwhals/_interchange/dataframe.py Outdated
Comment on lines +145 to +147
@property
def _native_frame(self) -> Original_co:
return self._df

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I added this alias temporarily so that to_native keeps working.

But we should probably update these guys:

if isinstance(narwhals_object, BaseFrame):
return narwhals_object._compliant_frame._native_frame
if isinstance(narwhals_object, Series):
return narwhals_object._compliant_series.native

-    if isinstance(narwhals_object, BaseFrame):
-        return narwhals_object._compliant_frame._native_frame
-    if isinstance(narwhals_object, Series):
-        return narwhals_object._compliant_series.native
+    if isinstance(narwhals_object, (BaseFrame, Series)):
+        return narwhals_object._compliant.native

Comment thread src/narwhals/_interchange/dataframe.py Outdated
@dangotbanned dangotbanned added ibis Issue is related to ibis backend tests labels Jun 30, 2026
@dangotbanned dangotbanned changed the title chore(WIP): Isolate interchange-level support chore: Isolate interchange-level support Jun 30, 2026
@dangotbanned dangotbanned marked this pull request as ready for review June 30, 2026 17:44
@dangotbanned

Copy link
Copy Markdown
Member Author

If anyone can point me towards evidence of downstream use cases (that aren't in the description) that this will break please let me know!

I spent some time searching, but I'm open to widening the API if shown something new 🙂

@dangotbanned

Copy link
Copy Markdown
Member Author

just noting that whatever solution we go with shouldn't require downstream libraries (marimo / altair / ....) to change anything on their side

@MarcoGorelli I'm still in agreement with you on this ...
but I think I've stumbled into an (existing) 1 performance issue for altair.

It looks to stem from (vega/altair#3452 (review)), which I think was well-intentioned.

Example

Here we have a very simple chart.

I was suprised to find that these steps didn't trigger from_native:

import altair as alt
import polars as pl

df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
chart = alt.Chart(df).mark_point().encode(x="x", y="y")

Reading back through (vega/altair#3452) - the reason was to avoid a copy to pyarrow.

This call 2 manages to hit from_native 9 times in total, with 3 different objects:

chart.to_dict()
Show logs (polars)

image

Now here's the same thing, but using duckdb:

import altair as alt
import polars as pl
import duckdb

df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
chart = alt.Chart(duckdb.from_arrow(df)).mark_point().encode(x="x", y="y")

This call increases to 10 times in total, with 4 different objects:

chart.to_dict()
Show logs (duckdb)

image

Questions

  1. Do these numbers surprise you?
  2. Does optimizing for __dataframe__ make sense when we don't use it in either of these cases?

Solution(s)?

For DuckDB there are two things that wouldn't have been available back in (vega/altair#3452):

  1. We can collect the schema (and then cache it) without loading the data
  2. __arrow_c_stream__ should ensure life begins at zero-copy 😉

For everything else, I feel like wrapping the native object early would be the place to start?

Footnotes

  1. I'm testing this from (https://github.com/narwhals-dev/narwhals/compare/expr-ir/docs/fluff-1...expr-ir/altair-expr), which doesn't have the changes from this branch

  2. which happpens when you repr the chart

@FBruzzesi FBruzzesi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks @dangotbanned - I left a couple of comments, the real blocker is _should_interchange regression and maybe implementation.

Another doubt I have is about the introduction of two new # pragma: no cover:

  • _duckdb/utils.py added if version is Version.V1: # pragma: no cover
  • _duckdb/dataframe.py added if validate_backend_version: # pragma: no cover

Comment thread src/narwhals/_interchange/dataframe.py Outdated
def _should_interchange(tp_native: type[Any]) -> TypeIs[type[DataFrameLike]]:
if not _hasattr_static(tp_native, "__dataframe__"):
return (duckdb := get_duckdb()) and issubclass(tp_native, duckdb.DuckDBPyRelation)
has_top_level_df = (get_polars, get_pandas, get_dask_dataframe, get_modin)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

cudf is missing from this list which would lead to a regression "demoting" cudf.DataFrame's to interchange level for v1.

More generally this function is an inversion of logic (from inclusion to exclusion) and seems quite hard to maintain/keep in sync, for many reasons, one of which is that we don't typically look nor touch interchange stuff anymore - what about dog-feeding _is_native_dataframe / _is_<known_to_exclude>_lazyframe?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

cudf is missing from this list which would lead to a regression "demoting" cudf.DataFrame's to interchange level for v1.

Well spotted!

So I started out thinking cudf supported interchange, but removed it when I couldn't find it anywhere

I'm looking again now and it looks like this is the support?

There is also a mention here of narwhals and interchange:

# polars 1.40 deprecated the DataFrame interchange protocol, which narwhals
# 2.16.0 still exercises; narwhals' filterwarnings=error turns the resulting
# DeprecationWarning into a failure. These run for every constructor, so skip
# them across all runs. test_get_level also relies on the interchange protocol.
# Remove once narwhals stops using the deprecated protocol.

And possibly this was when it got removed:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

More generally this function is an inversion of logic (from inclusion to exclusion) and seems quite hard to maintain/keep in sync, for many reasons, one of which is that we don't typically look nor touch interchange stuff anymore

I'll do my best to walk through this tomorrow and explain how I landed here - there were a couple of concerns I was trying to balance

In terms of keeping in sync, IIUC this logic is frozen - which is why I opted to make it the case to handle first and keep everything confined to v1/"interchange"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Yes CuDF supports (or at least supported) interchange and is deprecating/ has deprecated it, but some versions we support definitely can enter such path.

@dangotbanned dangotbanned Jul 3, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@FBruzzesi hopefully these guys are helpful (https://github.com/narwhals-dev/narwhals/pull/3721/files/4bbdf114e109163077ab22bcab1c622f93dc3f23..955a677a1ebd5960686a8f13ebd90b91fb91c432)

Another thing I could speak on, but didn't address in those is: Why is this not using is_*_dataframe? or why is this checking on the type?

They're kinda the same question really 😅

I wanted to remove the concept of interchange from translate.py and v2, where it still lingered since (#2814).

So my choices were:

  1. Repeat all of _from_native_impl in v1, with the tweaks that allow "interchange"
  2. Handle "interchange" in v1, then defer to main

I picked option 2, since it requires less maintenance.

I also saw a way that option 2 could use caching for the very complicated checking 🙂.
But, that needed to switch to using the type vs the instance and so I couldn't reuse the existing guards

msg = (
f"{method_name!r} is not supported for interchange-level dataframes.\n\n"
"Hint: you probably called `from_native` on an object which isn't fully "
"supported by `narwhals.stable.v1`, yet implements `__dataframe__`."

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

If this can trigger from a DuckDBPyRelation, then that's actually not the case: it does not implement __dataframe__ (not sure about ibis case)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This is a pretty interesting point.

What is being called interchange-level actually is entirely disjoint from it 😂

DuckDBPyRelation it does not implement __dataframe__

Which was news to be me before I started this PR!

(not sure about ibis case)

ibis does support it and I was using that directly in an earlier version (https://github.com/narwhals-dev/narwhals/blob/b7e533d9f2e10be02713617b3cfb556eb236da66/src/narwhals/_ibis/interchange.py)

I don't know what exactly to call this support - but I'll take any name you can think of 🙏

Comment thread src/narwhals/_duckdb/dataframe.py
@dangotbanned

dangotbanned commented Jul 2, 2026

Copy link
Copy Markdown
Member Author

Thanks for the review @FBruzzesi

Another doubt I have is about the introduction of two new # pragma: no cover:

I believe all of those are from tests/v1_test.py diff and these commits:

Essentially - I found no downstream using any of this and it has never been documented as working 1.

I will add things back if needed (#3721 (comment)) - but I would like to see the use case 🙂

What we have here is what people are using, which is very similar to (#3558 (comment)), but also to_native is used by Marimo - so there are a couple extras

"native",
"__native_namespace__",
"_native_frame",
"schema",
"columns",
"collect_schema",

def simple_select(self, *column_names: str) -> Self:
return self.from_compliant(self._compliant.simple_select(*column_names))
def get_column(self, name: str) -> Series[NativeT]:
return Series(self.simple_select(name))
def __narwhals_dataframe__(self) -> Self:

def to_pandas(self) -> pd.DataFrame: ...
def to_arrow(self) -> pa.Table: ...

Footnotes

  1. Which I have to assume is why nobody depended on/knew about it 😉

return duckdb_type
if isinstance_or_issubclass(dtype, dtypes.Enum):
if version is Version.V1:
if version is Version.V1: # pragma: no cover

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Probably should remove this branch (same reason as #3721 (comment))

Comment on lines +186 to +195
We *could* use them with interchange-level support - but we have a better option at home.

Sadly this type is [not yet](https://www.youtube.com/watch?v=xUsPD7iwETY) spellable:

(DataFrameLike & (pl.DataFrame | pd.DataFrame | pa.Table | dd.DataFrame | mpd.DataFrame | cudf.DataFrame)
# | union
# intersection

We gathers all the types (that are already imported) which fit the the right-hand-side,
as we need to refine from the set produced by checking `__dataframe__`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Best description I read in a while 😂

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

Labels

duckdb Issue is related to duckdb backend ibis Issue is related to ibis backend internal tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants