Repository: narwhals-dev/narwhals
Pull request #559 (2024-07-20 08:38:52)

docs: docstrings narwhalify-cation
## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [x] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added 
- [x] Documented the changes

## If you have comments or can explain your changes, please do so below.

Docstring standardization using `@nw.narwhalify` and `df|s_any` pattern


FBruzzesi: > The @narwhalify are much cleaner, thanks

🙌🏼✨


> I'm less sure about the name changes

I tried to think from the point of view of somebody reading the docstrings for the first time. For some reason, `*_any` resonates more with me in terms of generality of what can be passed along, but I have nothing against keeping it `df|series`


Patch:
diff --git a/docs/how_it_works.md b/docs/how_it_works.md
index 9bea4cc6..4bba89e0 100644
--- a/docs/how_it_works.md
+++ b/docs/how_it_works.md
@@ -58,28 +58,8 @@ it to:
 
 Now let's turn our attention to the implementation.
 
-## Polars implementation
-
-For Polars, Narwhals just "passes everything through". For example consider the following:
-```python exec="1"
-import polars as pl
-import narwhals as nw
-
-df_pl = pl.DataFrame({'a': [1,2,3], 'b': [4,5,6]})
-df = nw.from_native(df_pl)
-df.select(nw.col('a')+1)
-```
-
-`nw.col('a')` produces a `narwhals.expression.Expr` object, which has a private `_call` method.
-Inside `DataFrame.select`, we call `nw.col('a')._call(pl)`, which produces `pl.col('a')`.
-
-We then let Polars do its thing. Which is nice, but also not particularly interesting.
-How about translating expressions to pandas? Well, it's
-interesting to us, and you're still reading, so maybe it'll be interesting to you too.
-
 ## pandas implementation
 
-When we called `nw.col('a')._call(pl)`, we got a Narwhals-compliant Polars namespace.
 The pandas namespace (`pd`) isn't Narwhals-compliant, as the pandas API is very different
 from Polars'. So...Narwhals implements a `PandasLikeNamespace`, which includes the top-level
 Polars functions included in the Narwhals API:
@@ -151,6 +131,24 @@ than running pandas directly.
 Further attempts at demistifying Narwhals, refactoring code so it's clearer, and explaining
 this section better are 110% welcome.
 
+## Polars and other implementations
+
+Other implementations are similar to the above: their define their own Narwhals-compliant
+objects. So, all-in-all, there are a couple of layers here:
+
+- `nw.DataFrame` is backed by a Narwhals-compliant Dataframe, such as:
+  - `narwhals._pandas_like.dataframe.PandasLikeDataFrame`
+  - `narwhals._arrow.dataframe.ArrowDataFrame`
+  - `narwhals._polars.dataframe.PolarsDataFrame`
+- each Narwhals-compliant DataFrame is backed by a native Dataframe, for example:
+  - `narwhals._pandas_like.dataframe.PandasLikeDataFrame` is backed by a pandas DataFrame
+  - `narwhals._arrow.dataframe.ArrowDataFrame` is backed by a PyArrow Table
+  - `narwhals._polars.dataframe.PolarsDataFrame` is backed by a Polars DataFrame
+
+Each implementation defines its own objects in subfolders such as `narwhals._pandas_like`,
+`narwhals._arrow`, `narwhals._polars`, whereas the top-level modules such as `narwhals.dataframe`
+and `narwhals.series` coordinate how to dispatch the Narwhals API to each backend.
+
 ## Group-by
 
 Group-by is probably one of Polars' most significant innovations (on the syntax side) with respect
diff --git a/docs/related.md b/docs/related.md
index 5c35db5b..682a18d4 100644
--- a/docs/related.md
+++ b/docs/related.md
@@ -5,34 +5,23 @@
 Standardised way of interchanging data between libraries, see
 [here](https://data-apis.org/dataframe-protocol/latest/index.html).
 
+Narwhals builds upon it by providing one level of support to libraries which implement it -
+this includes Ibis and Vaex. See [levels](levels.md) for details.
+
 ## DataFrame API Standard
 
 Now-discontinued project which aimed to "provide a standard interface that encapsulates implementation details of dataframe libraries. This will allow users and third-party libraries to write code that interacts and operates with a standard dataframe, and not with specific implementations.", see [here](https://data-apis.org/dataframe-api/draft/).
 
-The Narwhals author was originally involved, but left due to irreconcilable differences in vision.
+The Narwhals author was originally involved, but left due to irreconcilable differences in vision, and
+the project was ultimately abandoned.
 
 Some notable difference are:
 
 - Narwhals just uses a subset of the Polars API, whereas the dataframe standard introduces a new API
 - Narwhals supports expressions, and separates lazy and eager execution
-- Narwhals is a standalone, independent project, whereas the dataframe standard aims to be upstreamed
+- Narwhals is a standalone, independent project, whereas the dataframe standard aimed to be upstreamed
   and implemented by major dataframe libraries.
 
-## Ibis
-
-[Presents itself as a dataframe standard](https://voltrondata.com/resources/open-source-standards), and
-dispatches to 20+ backends. Some differences with Narwhals are:
-
-- Narwhals is aimed at library maintainers, Ibis more to end users
-- Narwhals has zero required dependencies, whereas Ibis requires pandas and PyArrow. For users starting
-  from non-pandas environments, the difference in the relative size increase is ~1000x
-- Narwhals only supports 4 backends, Ibis more than 20
-- Narwhals is focused on fundamental dataframe operations, Ibis on SQL backends
-- Narwhals has negligible overhead for dataframe backends, while
-  [Ibis' overhead can be significant](https://github.com/ibis-project/ibis/issues/9345)
-
-The projects are not in competition and have different goals.
-
 ## Array API
 
 Array counterpart to the DataFrame API, see [here](https://data-apis.org/array-api/2022.12/index.html).
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 94dccd0c..7526e528 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -14,6 +14,7 @@ from narwhals.dependencies import get_numpy
 from narwhals.dependencies import get_pyarrow
 from narwhals.dependencies import get_pyarrow_parquet
 from narwhals.utils import flatten
+from narwhals.utils import generate_unique_token
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -208,7 +209,7 @@ class ArrowDataFrame:
         self,
         other: Self,
         *,
-        how: Literal["inner"] = "inner",
+        how: Literal["left", "inner", "outer", "cross", "anti", "semi"] = "inner",
         left_on: str | list[str] | None,
         right_on: str | list[str] | None,
     ) -> Self:
@@ -217,24 +218,35 @@ class ArrowDataFrame:
         if isinstance(right_on, str):
             right_on = [right_on]
 
-        if how == "cross":  # type: ignore[comparison-overlap]
-            raise NotImplementedError
-
-        if how == "anti":  # type: ignore[comparison-overlap]
-            raise NotImplementedError
+        how_to_join_map = {
+            "anti": "left anti",
+            "semi": "left semi",
+            "inner": "inner",
+            "left": "left outer",
+        }
 
-        if how == "semi":  # type: ignore[comparison-overlap]
-            raise NotImplementedError
+        if how == "cross":
+            plx = self.__narwhals_namespace__()
+            key_token = generate_unique_token(
+                n_bytes=8, columns=[*self.columns, *other.columns]
+            )
 
-        if how == "left":  # type: ignore[comparison-overlap]
-            raise NotImplementedError
+            return self._from_native_dataframe(
+                self.with_columns(**{key_token: plx.lit(0, None)})._native_dataframe.join(
+                    other.with_columns(**{key_token: plx.lit(0, None)})._native_dataframe,
+                    keys=key_token,
+                    right_keys=key_token,
+                    join_type="inner",
+                    right_suffix="_right",
+                ),
+            ).drop(key_token)
 
         return self._from_native_dataframe(
             self._native_dataframe.join(
                 other._native_dataframe,
                 keys=left_on,
                 right_keys=right_on,
-                join_type=how,
+                join_type=how_to_join_map[how],
                 right_suffix="_right",
             ),
         )
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index f8bfbffc..17398300 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -144,6 +144,9 @@ class ArrowExpr:
     def count(self) -> Self:
         return reuse_series_implementation(self, "count", returns_scalar=True)
 
+    def n_unique(self) -> Self:
+        return reuse_series_implementation(self, "n_unique", returns_scalar=True)
+
     def std(self, ddof: int = 1) -> Self:
         return reuse_series_implementation(self, "std", ddof=ddof, returns_scalar=True)
 
@@ -212,6 +215,9 @@ class ArrowExpr:
             self, "sample", n=n, fraction=fraction, with_replacement=with_replacement
         )
 
+    def fill_null(self: Self, value: Any) -> Self:
+        return reuse_series_implementation(self, "fill_null", value=value)
+
     @property
     def dt(self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
@@ -251,6 +257,40 @@ class ArrowExprStringNamespace:
     def __init__(self, expr: ArrowExpr) -> None:
         self._expr = expr
 
+    def starts_with(self, prefix: str) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr,
+            "str",
+            "starts_with",
+            prefix,
+        )
+
+    def ends_with(self, suffix: str) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr,
+            "str",
+            "ends_with",
+            suffix,
+        )
+
+    def contains(self, pattern: str, *, literal: bool) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "str", "contains", pattern, literal=literal
+        )
+
+    def slice(self, offset: int, length: int | None = None) -> ArrowExpr:
+        return reuse_series_namespace_implementation(
+            self._expr, "str", "slice", offset, length
+        )
+
+    def to_datetime(self, format: str | None = None) -> ArrowExpr:  # noqa: A002
+        return reuse_series_namespace_implementation(
+            self._expr,
+            "str",
+            "to_datetime",
+            format,
+        )
+
     def to_uppercase(self) -> ArrowExpr:
         return reuse_series_namespace_implementation(
             self._expr,
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index 40e1d1b7..bc045565 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -8,6 +8,7 @@ from typing import Iterable
 from narwhals import dtypes
 from narwhals._arrow.dataframe import ArrowDataFrame
 from narwhals._arrow.expr import ArrowExpr
+from narwhals._arrow.selectors import ArrowSelectorNamespace
 from narwhals._arrow.series import ArrowSeries
 from narwhals._arrow.utils import horizontal_concat
 from narwhals._arrow.utils import vertical_concat
@@ -198,3 +199,7 @@ class ArrowNamespace:
                 backend_version=self._backend_version,
             )
         raise NotImplementedError
+
+    @property
+    def selectors(self) -> ArrowSelectorNamespace:
+        return ArrowSelectorNamespace(backend_version=self._backend_version)
diff --git a/narwhals/_arrow/selectors.py b/narwhals/_arrow/selectors.py
new file mode 100644
index 00000000..b8ba5bf5
--- /dev/null
+++ b/narwhals/_arrow/selectors.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import NoReturn
+
+from narwhals import dtypes
+from narwhals._arrow.expr import ArrowExpr
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+    from narwhals._arrow.dataframe import ArrowDataFrame
+    from narwhals._arrow.series import ArrowSeries
+    from narwhals.dtypes import DType
+
+
+class ArrowSelectorNamespace:
+    def __init__(self: Self, *, backend_version: tuple[int, ...]) -> None:
+        self._backend_version = backend_version
+
+    def by_dtype(self: Self, dtypes: list[DType | type[DType]]) -> ArrowSelector:
+        def func(df: ArrowDataFrame) -> list[ArrowSeries]:
+            return [df[col] for col in df.columns if df.schema[col] in dtypes]
+
+        return ArrowSelector(
+            func,
+            depth=0,
+            function_name="type_selector",
+            root_names=None,
+            output_names=None,
+            backend_version=self._backend_version,
+        )
+
+    def numeric(self: Self) -> ArrowSelector:
+        return self.by_dtype(
+            [
+                dtypes.Int64,
+                dtypes.Int32,
+                dtypes.Int16,
+                dtypes.Int8,
+                dtypes.UInt64,
+                dtypes.UInt32,
+                dtypes.UInt16,
+                dtypes.UInt8,
+                dtypes.Float64,
+                dtypes.Float32,
+            ],
+        )
+
+    def categorical(self: Self) -> ArrowSelector:
+        return self.by_dtype([dtypes.Categorical])
+
+    def string(self: Self) -> ArrowSelector:
+        return self.by_dtype([dtypes.String])
+
+    def boolean(self: Self) -> ArrowSelector:
+        return self.by_dtype([dtypes.Boolean])
+
+    def all(self: Self) -> ArrowSelector:
+        def func(df: ArrowDataFrame) -> list[ArrowSeries]:
+            return [df[col] for col in df.columns]
+
+        return ArrowSelector(
+            func,
+            depth=0,
+            function_name="type_selector",
+            root_names=None,
+            output_names=None,
+            backend_version=self._backend_version,
+        )
+
+
+class ArrowSelector(ArrowExpr):
+    def __repr__(self: Self) -> str:  # pragma: no cover
+        return (
+            f"ArrowSelector("
+            f"depth={self._depth}, "
+            f"function_name={self._function_name}, "
+            f"root_names={self._root_names}, "
+            f"output_names={self._output_names}"
+        )
+
+    def _to_expr(self: Self) -> ArrowExpr:
+        return ArrowExpr(
+            self._call,
+            depth=self._depth,
+            function_name=self._function_name,
+            root_names=self._root_names,
+            output_names=self._output_names,
+            backend_version=self._backend_version,
+        )
+
+    def __sub__(self: Self, other: Self | Any) -> ArrowSelector | Any:
+        if isinstance(other, ArrowSelector):
+
+            def call(df: ArrowDataFrame) -> list[ArrowSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name not in [x.name for x in rhs]]
+
+            return ArrowSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                backend_version=self._backend_version,
+            )
+        else:
+            return self._to_expr() - other
+
+    def __or__(self: Self, other: Self | Any) -> ArrowSelector | Any:
+        if isinstance(other, ArrowSelector):
+
+            def call(df: ArrowDataFrame) -> list[ArrowSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name not in [x.name for x in rhs]] + rhs
+
+            return ArrowSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                backend_version=self._backend_version,
+            )
+        else:
+            return self._to_expr() | other
+
+    def __and__(self: Self, other: Self | Any) -> ArrowSelector | Any:
+        if isinstance(other, ArrowSelector):
+
+            def call(df: ArrowDataFrame) -> list[ArrowSeries]:
+                lhs = self._call(df)
+                rhs = other._call(df)
+                return [x for x in lhs if x.name in [x.name for x in rhs]]
+
+            return ArrowSelector(
+                call,
+                depth=0,
+                function_name="type_selector",
+                root_names=None,
+                output_names=None,
+                backend_version=self._backend_version,
+            )
+        else:
+            return self._to_expr() & other
+
+    def __invert__(self: Self) -> ArrowSelector:
+        return ArrowSelectorNamespace(backend_version=self._backend_version).all() - self
+
+    def __rsub__(self: Self, other: Any) -> NoReturn:
+        raise NotImplementedError
+
+    def __rand__(self: Self, other: Any) -> NoReturn:
+        raise NotImplementedError
+
+    def __ror__(self: Self, other: Any) -> NoReturn:
+        raise NotImplementedError
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index c287dd81..331d9a4a 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -175,6 +175,12 @@ class ArrowSeries:
         pc = get_pyarrow_compute()
         return pc.count(self._native_series)  # type: ignore[no-any-return]
 
+    def n_unique(self) -> int:
+        pc = get_pyarrow_compute()
+        unique_values = pc.unique(self._native_series)
+        count_unique = pc.count(unique_values, mode="all")
+        return count_unique.as_py()  # type: ignore[no-any-return]
+
     def __narwhals_namespace__(self) -> ArrowNamespace:
         from narwhals._arrow.namespace import ArrowNamespace
 
@@ -314,6 +320,14 @@ class ArrowSeries:
         mask = np.random.choice(idx, size=n, replace=with_replacement)
         return self._from_native_series(pc.take(ser, mask))
 
+    def fill_null(self: Self, value: Any) -> Self:
+        pa = get_pyarrow()
+        pc = get_pyarrow_compute()
+        ser = self._native_series
+        dtype = ser.type
+
+        return self._from_native_series(pc.fill_null(ser, pa.scalar(value, dtype)))
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
@@ -361,16 +375,50 @@ class ArrowSeriesCatNamespace:
 
 
 class ArrowSeriesStringNamespace:
-    def __init__(self, series: ArrowSeries) -> None:
+    def __init__(self: Self, series: ArrowSeries) -> None:
         self._arrow_series = series
 
-    def to_uppercase(self) -> ArrowSeries:
+    def starts_with(self: Self, prefix: str) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        return self._arrow_series._from_native_series(
+            pc.equal(self.slice(0, len(prefix))._native_series, prefix)
+        )
+
+    def ends_with(self: Self, suffix: str) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        return self._arrow_series._from_native_series(
+            pc.equal(self.slice(-len(suffix))._native_series, suffix)
+        )
+
+    def contains(self: Self, pattern: str, *, literal: bool = False) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        check_func = pc.match_substring if literal else pc.match_substring_regex
+        return self._arrow_series._from_native_series(
+            check_func(self._arrow_series._native_series, pattern)
+        )
+
+    def slice(self: Self, offset: int, length: int | None = None) -> ArrowSeries:
+        pc = get_pyarrow_compute()
+        stop = offset + length if length else None
+        return self._arrow_series._from_native_series(
+            pc.utf8_slice_codeunits(
+                self._arrow_series._native_series, start=offset, stop=stop
+            ),
+        )
+
+    def to_datetime(self: Self, format: str | None = None) -> ArrowSeries:  # noqa: A002
+        pc = get_pyarrow_compute()
+        return self._arrow_series._from_native_series(
+            pc.strptime(self._arrow_series._native_series, format=format, unit="us")
+        )
+
+    def to_uppercase(self: Self) -> ArrowSeries:
         pc = get_pyarrow_compute()
         return self._arrow_series._from_native_series(
             pc.utf8_upper(self._arrow_series._native_series),
         )
 
-    def to_lowercase(self) -> ArrowSeries:
+    def to_lowercase(self: Self) -> ArrowSeries:
         pc = get_pyarrow_compute()
         return self._arrow_series._from_native_series(
             pc.utf8_lower(self._arrow_series._native_series),
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index fa6e707c..6606e8f1 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -13,7 +13,6 @@ from narwhals._expression_parsing import evaluate_into_exprs
 from narwhals._pandas_like.expr import PandasLikeExpr
 from narwhals._pandas_like.utils import Implementation
 from narwhals._pandas_like.utils import create_native_series
-from narwhals._pandas_like.utils import generate_unique_token
 from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import translate_dtype
 from narwhals._pandas_like.utils import validate_dataframe_comparand
@@ -23,6 +22,7 @@ from narwhals.dependencies import get_modin
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import get_pandas
 from narwhals.utils import flatten
+from narwhals.utils import generate_unique_token
 
 if TYPE_CHECKING:
     from typing_extensions import Self
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 3548966c..b493cfed 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import secrets
 from enum import Enum
 from enum import auto
 from typing import TYPE_CHECKING
@@ -458,31 +457,3 @@ def int_dtype_mapper(dtype: Any) -> str:
     if str(dtype).lower() != str(dtype):  # pragma: no cover
         return "Int64"
     return "int64"
-
-
-def generate_unique_token(n_bytes: int, columns: list[str]) -> str:  # pragma: no cover
-    """Generates a unique token of specified n_bytes that is not present in the given list of columns.
-
-    Arguments:
-        n_bytes : The number of bytes to generate for the token.
-        columns : The list of columns to check for uniqueness.
-
-    Returns:
-        A unique token that is not present in the given list of columns.
-
-    Raises:
-        AssertionError: If a unique token cannot be generated after 100 attempts.
-    """
-    counter = 0
-    while True:
-        token = secrets.token_hex(n_bytes)
-        if token not in columns:
-            return token
-
-        counter += 1
-        if counter > 100:
-            msg = (
-                "Internal Error: Narwhals was not able to generate a column name to perform cross "
-                "join operation"
-            )
-            raise AssertionError(msg)
diff --git a/narwhals/_polars/__init__.py b/narwhals/_polars/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
new file mode 100644
index 00000000..70a24c05
--- /dev/null
+++ b/narwhals/_polars/dataframe.py
@@ -0,0 +1,188 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from narwhals._polars.namespace import PolarsNamespace
+from narwhals._polars.utils import extract_args_kwargs
+from narwhals._polars.utils import translate_dtype
+from narwhals.dependencies import get_polars
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+
+class PolarsDataFrame:
+    def __init__(self, df: Any, *, backend_version: tuple[int, ...]) -> None:
+        self._native_dataframe = df
+        self._backend_version = backend_version
+
+    def __repr__(self) -> str:  # pragma: no cover
+        return "PolarsDataFrame"
+
+    def __narwhals_dataframe__(self) -> Self:
+        return self
+
+    def __narwhals_namespace__(self) -> PolarsNamespace:
+        return PolarsNamespace(backend_version=self._backend_version)
+
+    def __native_namespace__(self) -> Any:
+        return get_polars()
+
+    def _from_native_dataframe(self, df: Any) -> Self:
+        return self.__class__(df, backend_version=self._backend_version)
+
+    def _from_native_object(self, obj: Any) -> Any:
+        pl = get_polars()
+        if isinstance(obj, pl.Series):
+            from narwhals._polars.series import PolarsSeries
+
+            return PolarsSeries(obj, backend_version=self._backend_version)
+        if isinstance(obj, pl.DataFrame):
+            return self._from_native_dataframe(obj)
+        # scalar
+        return obj
+
+    def __getattr__(self, attr: str) -> Any:
+        if attr == "collect":  # pragma: no cover
+            raise AttributeError
+
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._from_native_object(
+                getattr(self._native_dataframe, attr)(*args, **kwargs)
+            )
+
+        return func
+
+    @property
+    def schema(self) -> dict[str, Any]:
+        schema = self._native_dataframe.schema
+        return {name: translate_dtype(dtype) for name, dtype in schema.items()}
+
+    def collect_schema(self) -> dict[str, Any]:
+        if self._backend_version < (1,):  # pragma: no cover
+            schema = self._native_dataframe.schema
+        else:
+            schema = dict(self._native_dataframe.collect_schema())
+        return {name: translate_dtype(dtype) for name, dtype in schema.items()}
+
+    @property
+    def shape(self) -> tuple[int, int]:
+        return self._native_dataframe.shape  # type: ignore[no-any-return]
+
+    def __getitem__(self, item: Any) -> Any:
+        pl = get_polars()
+        result = self._native_dataframe.__getitem__(item)
+        if isinstance(result, pl.Series):
+            from narwhals._polars.series import PolarsSeries
+
+            return PolarsSeries(result, backend_version=self._backend_version)
+        return self._from_native_object(result)
+
+    def get_column(self, name: str) -> Any:
+        from narwhals._polars.series import PolarsSeries
+
+        return PolarsSeries(
+            self._native_dataframe.get_column(name), backend_version=self._backend_version
+        )
+
+    def is_empty(self) -> bool:
+        return len(self._native_dataframe) == 0
+
+    @property
+    def columns(self) -> list[str]:
+        return self._native_dataframe.columns  # type: ignore[no-any-return]
+
+    def lazy(self) -> PolarsLazyFrame:
+        return PolarsLazyFrame(
+            self._native_dataframe.lazy(), backend_version=self._backend_version
+        )
+
+    def to_dict(self, *, as_series: bool) -> Any:
+        df = self._native_dataframe
+
+        if as_series:
+            from narwhals._polars.series import PolarsSeries
+
+            return {
+                name: PolarsSeries(col, backend_version=self._backend_version)
+                for name, col in df.to_dict(as_series=True).items()
+            }
+        else:
+            return df.to_dict(as_series=False)
+
+    def group_by(self, by: list[str]) -> Any:
+        from narwhals._polars.group_by import PolarsGroupBy
+
+        return PolarsGroupBy(self, by)
+
+    def with_row_index(self, name: str) -> Any:
+        if self._backend_version < (0, 20, 4):  # pragma: no cover
+            return self._from_native_dataframe(
+                self._native_dataframe.with_row_count(name)
+            )
+        return self._from_native_dataframe(self._native_dataframe.with_row_index(name))
+
+
+class PolarsLazyFrame:
+    def __init__(self, df: Any, *, backend_version: tuple[int, ...]) -> None:
+        self._native_dataframe = df
+        self._backend_version = backend_version
+
+    def __repr__(self) -> str:  # pragma: no cover
+        return "PolarsLazyFrame"
+
+    def __narwhals_lazyframe__(self) -> Self:
+        return self
+
+    def __narwhals_namespace__(self) -> PolarsNamespace:
+        return PolarsNamespace(backend_version=self._backend_version)
+
+    def __native_namespace__(self) -> Any:  # pragma: no cover
+        return get_polars()
+
+    def _from_native_dataframe(self, df: Any) -> Self:
+        return self.__class__(df, backend_version=self._backend_version)
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._from_native_dataframe(
+                getattr(self._native_dataframe, attr)(*args, **kwargs)
+            )
+
+        return func
+
+    @property
+    def columns(self) -> list[str]:
+        return self._native_dataframe.columns  # type: ignore[no-any-return]
+
+    @property
+    def schema(self) -> dict[str, Any]:
+        schema = self._native_dataframe.schema
+        return {name: translate_dtype(dtype) for name, dtype in schema.items()}
+
+    def collect_schema(self) -> dict[str, Any]:
+        if self._backend_version < (1,):  # pragma: no cover
+            schema = self._native_dataframe.schema
+        else:
+            schema = dict(self._native_dataframe.collect_schema())
+        return {name: translate_dtype(dtype) for name, dtype in schema.items()}
+
+    def collect(self) -> PolarsDataFrame:
+        return PolarsDataFrame(
+            self._native_dataframe.collect(), backend_version=self._backend_version
+        )
+
+    def group_by(self, by: list[str]) -> Any:
+        from narwhals._polars.group_by import PolarsLazyGroupBy
+
+        return PolarsLazyGroupBy(self, by)
+
+    def with_row_index(self, name: str) -> Any:
+        if self._backend_version < (0, 20, 4):  # pragma: no cover
+            return self._from_native_dataframe(
+                self._native_dataframe.with_row_count(name)
+            )
+        return self._from_native_dataframe(self._native_dataframe.with_row_index(name))
diff --git a/narwhals/_polars/expr.py b/narwhals/_polars/expr.py
new file mode 100644
index 00000000..779bafcf
--- /dev/null
+++ b/narwhals/_polars/expr.py
@@ -0,0 +1,149 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+    from narwhals.dtypes import DType
+from narwhals._polars.namespace import PolarsNamespace
+from narwhals._polars.utils import extract_args_kwargs
+from narwhals._polars.utils import extract_native
+from narwhals._polars.utils import reverse_translate_dtype
+
+
+class PolarsExpr:
+    def __init__(self, expr: Any) -> None:
+        self._native_expr = expr
+
+    def __repr__(self) -> str:  # pragma: no cover
+        return "PolarsExpr"
+
+    def __narwhals_expr__(self) -> Self:  # pragma: no cover
+        return self
+
+    def __narwhals_namespace__(self) -> PolarsNamespace:  # pragma: no cover
+        return PolarsNamespace(backend_version=self._backend_version)
+
+    def _from_native_expr(self, expr: Any) -> Self:
+        return self.__class__(expr)
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._from_native_expr(
+                getattr(self._native_expr, attr)(*args, **kwargs)
+            )
+
+        return func
+
+    def cast(self, dtype: DType) -> Self:
+        expr = self._native_expr
+        dtype = reverse_translate_dtype(dtype)
+        return self._from_native_expr(expr.cast(dtype))
+
+    def __eq__(self, other: object) -> Self:  # type: ignore[override]
+        return self._from_native_expr(self._native_expr.__eq__(extract_native(other)))
+
+    def __ne__(self, other: object) -> Self:  # type: ignore[override]
+        return self._from_native_expr(self._native_expr.__ne__(extract_native(other)))
+
+    def __ge__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__ge__(extract_native(other)))
+
+    def __gt__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__gt__(extract_native(other)))
+
+    def __le__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__le__(extract_native(other)))
+
+    def __lt__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__lt__(extract_native(other)))
+
+    def __and__(self, other: PolarsExpr | bool | Any) -> Self:
+        return self._from_native_expr(self._native_expr.__and__(extract_native(other)))
+
+    def __or__(self, other: PolarsExpr | bool | Any) -> Self:
+        return self._from_native_expr(self._native_expr.__or__(extract_native(other)))
+
+    def __add__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__add__(extract_native(other)))
+
+    def __radd__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__radd__(extract_native(other)))
+
+    def __sub__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__sub__(extract_native(other)))
+
+    def __rsub__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__rsub__(extract_native(other)))
+
+    def __mul__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__mul__(extract_native(other)))
+
+    def __rmul__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__rmul__(extract_native(other)))
+
+    def __pow__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__pow__(extract_native(other)))
+
+    def __rpow__(self, other: Any) -> Self:
+        return self._from_native_expr(self._native_expr.__rpow__(extract_native(other)))
+
+    def __invert__(self) -> Self:
+        return self._from_native_expr(self._native_expr.__invert__())
+
+    @property
+    def dt(self) -> PolarsExprDateTimeNamespace:
+        return PolarsExprDateTimeNamespace(self)
+
+    @property
+    def str(self) -> PolarsExprStringNamespace:
+        return PolarsExprStringNamespace(self)
+
+    @property
+    def cat(self) -> PolarsExprCatNamespace:
+        return PolarsExprCatNamespace(self)
+
+
+class PolarsExprDateTimeNamespace:
+    def __init__(self, expr: PolarsExpr) -> None:
+        self._expr = expr
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._expr._from_native_expr(
+                getattr(self._expr._native_expr.dt, attr)(*args, **kwargs)
+            )
+
+        return func
+
+
+class PolarsExprStringNamespace:
+    def __init__(self, expr: PolarsExpr) -> None:
+        self._expr = expr
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._expr._from_native_expr(
+                getattr(self._expr._native_expr.str, attr)(*args, **kwargs)
+            )
+
+        return func
+
+
+class PolarsExprCatNamespace:
+    def __init__(self, expr: PolarsExpr) -> None:
+        self._expr = expr
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._expr._from_native_expr(
+                getattr(self._expr._native_expr.cat, attr)(*args, **kwargs)
+            )
+
+        return func
diff --git a/narwhals/_polars/group_by.py b/narwhals/_polars/group_by.py
new file mode 100644
index 00000000..c0de7573
--- /dev/null
+++ b/narwhals/_polars/group_by.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from narwhals._polars.utils import extract_args_kwargs
+
+if TYPE_CHECKING:
+    from narwhals._polars.dataframe import PolarsDataFrame
+    from narwhals._polars.dataframe import PolarsLazyFrame
+
+
+class PolarsGroupBy:
+    def __init__(self, df: Any, keys: list[str]) -> None:
+        self._compliant_frame = df
+        self.keys = keys
+        self._grouped = df._native_dataframe.group_by(keys)
+
+    def agg(self, *aggs: Any, **named_aggs: Any) -> PolarsDataFrame:
+        aggs, named_aggs = extract_args_kwargs(aggs, named_aggs)  # type: ignore[assignment]
+        return self._compliant_frame._from_native_dataframe(  # type: ignore[no-any-return]
+            self._grouped.agg(*aggs, **named_aggs),
+        )
+
+    def __iter__(self) -> Any:
+        for key, df in self._grouped:
+            yield tuple(key), self._compliant_frame._from_native_dataframe(df)
+
+
+class PolarsLazyGroupBy:
+    def __init__(self, df: Any, keys: list[str]) -> None:
+        self._compliant_frame = df
+        self.keys = keys
+        self._grouped = df._native_dataframe.group_by(keys)
+
+    def agg(self, *aggs: Any, **named_aggs: Any) -> PolarsLazyFrame:
+        aggs, named_aggs = extract_args_kwargs(aggs, named_aggs)  # type: ignore[assignment]
+        return self._compliant_frame._from_native_dataframe(  # type: ignore[no-any-return]
+            self._grouped.agg(*aggs, **named_aggs),
+        )
diff --git a/narwhals/_polars/namespace.py b/narwhals/_polars/namespace.py
new file mode 100644
index 00000000..ed86c07e
--- /dev/null
+++ b/narwhals/_polars/namespace.py
@@ -0,0 +1,128 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Iterable
+from typing import Sequence
+
+from narwhals import dtypes
+from narwhals._polars.utils import extract_args_kwargs
+from narwhals._polars.utils import reverse_translate_dtype
+from narwhals.dependencies import get_polars
+
+if TYPE_CHECKING:
+    from narwhals._polars.dataframe import PolarsDataFrame
+    from narwhals._polars.dataframe import PolarsLazyFrame
+    from narwhals._polars.expr import PolarsExpr
+
+
+class PolarsNamespace:
+    Int64 = dtypes.Int64
+    Int32 = dtypes.Int32
+    Int16 = dtypes.Int16
+    Int8 = dtypes.Int8
+    UInt64 = dtypes.UInt64
+    UInt32 = dtypes.UInt32
+    UInt16 = dtypes.UInt16
+    UInt8 = dtypes.UInt8
+    Float64 = dtypes.Float64
+    Float32 = dtypes.Float32
+    Boolean = dtypes.Boolean
+    Object = dtypes.Object
+    Unknown = dtypes.Unknown
+    Categorical = dtypes.Categorical
+    Enum = dtypes.Enum
+    String = dtypes.String
+    Datetime = dtypes.Datetime
+    Duration = dtypes.Duration
+    Date = dtypes.Date
+
+    def __init__(self, *, backend_version: tuple[int, ...]) -> None:
+        self._backend_version = backend_version
+
+    def __getattr__(self, attr: str) -> Any:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return PolarsExpr(getattr(pl, attr)(*args, **kwargs))
+
+        return func
+
+    def len(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        if self._backend_version < (0, 20, 4):  # pragma: no cover
+            return PolarsExpr(pl.count().alias("len"))
+        return PolarsExpr(pl.len())
+
+    def concat(
+        self,
+        items: Sequence[PolarsDataFrame | PolarsLazyFrame],
+        *,
+        how: str = "vertical",
+    ) -> PolarsDataFrame | PolarsLazyFrame:
+        from narwhals._polars.dataframe import PolarsDataFrame
+        from narwhals._polars.dataframe import PolarsLazyFrame
+
+        pl = get_polars()
+        dfs: list[Any] = [item._native_dataframe for item in items]
+        result = pl.concat(dfs, how=how)
+        if isinstance(result, pl.DataFrame):
+            return PolarsDataFrame(result, backend_version=items[0]._backend_version)
+        return PolarsLazyFrame(result, backend_version=items[0]._backend_version)
+
+    def lit(self, value: Any, dtype: dtypes.DType | None = None) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        if dtype is not None:
+            return PolarsExpr(pl.lit(value, dtype=reverse_translate_dtype(dtype)))
+        return PolarsExpr(pl.lit(value))
+
+    @property
+    def selectors(self) -> PolarsSelectors:
+        return PolarsSelectors()
+
+
+class PolarsSelectors:
+    def by_dtype(self, dtypes: Iterable[dtypes.DType]) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(
+            pl.selectors.by_dtype([reverse_translate_dtype(dtype) for dtype in dtypes])
+        )
+
+    def numeric(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(pl.selectors.numeric())
+
+    def boolean(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(pl.selectors.boolean())
+
+    def string(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(pl.selectors.string())
+
+    def categorical(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(pl.selectors.categorical())
+
+    def all(self) -> PolarsExpr:
+        from narwhals._polars.expr import PolarsExpr
+
+        pl = get_polars()
+        return PolarsExpr(pl.selectors.all())
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
new file mode 100644
index 00000000..372d83ad
--- /dev/null
+++ b/narwhals/_polars/series.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+from narwhals._polars.utils import extract_args_kwargs
+from narwhals._polars.utils import extract_native
+from narwhals.dependencies import get_polars
+
+if TYPE_CHECKING:
+    import numpy as np
+    from typing_extensions import Self
+
+    from narwhals.dtypes import DType
+
+from narwhals._polars.namespace import PolarsNamespace
+from narwhals._polars.utils import reverse_translate_dtype
+from narwhals._polars.utils import translate_dtype
+
+PL = get_polars()
+
+
+class PolarsSeries:
+    def __init__(self, series: Any, *, backend_version: tuple[int, ...]) -> None:
+        self._native_series = series
+        self._backend_version = backend_version
+
+    def __repr__(self) -> str:  # pragma: no cover
+        return "PolarsSeries"
+
+    def __narwhals_series__(self) -> Self:
+        return self
+
+    def __native_namespace__(self) -> Any:
+        return get_polars()
+
+    def __narwhals_namespace__(self) -> PolarsNamespace:
+        return PolarsNamespace(backend_version=self._backend_version)
+
+    def _from_native_series(self, series: Any) -> Self:
+        return self.__class__(series, backend_version=self._backend_version)
+
+    def _from_native_object(self, series: Any) -> Any:
+        pl = get_polars()
+        if isinstance(series, pl.Series):
+            return self._from_native_series(series)
+        if isinstance(series, pl.DataFrame):
+            from narwhals._polars.dataframe import PolarsDataFrame
+
+            return PolarsDataFrame(series, backend_version=self._backend_version)
+        # scalar
+        return series
+
+    def __getattr__(self, attr: str) -> Any:
+        if attr == "as_py":  # pragma: no cover
+            raise AttributeError
+
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._from_native_object(
+                getattr(self._native_series, attr)(*args, **kwargs)
+            )
+
+        return func
+
+    def __len__(self) -> int:
+        return len(self._native_series)
+
+    @property
+    def shape(self) -> tuple[int]:
+        return (len(self),)
+
+    @property
+    def name(self) -> str:
+        return self._native_series.name  # type: ignore[no-any-return]
+
+    @property
+    def dtype(self) -> DType:
+        return translate_dtype(self._native_series.dtype)
+
+    def __getitem__(self, item: Any) -> Any:
+        return self._from_native_object(self._native_series.__getitem__(item))
+
+    def cast(self, dtype: DType) -> Self:
+        ser = self._native_series
+        dtype = reverse_translate_dtype(dtype)
+        return self._from_native_series(ser.cast(dtype))
+
+    def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
+        if self._backend_version < (0, 20, 29):  # pragma: no cover
+            return self._native_series.__array__(dtype=dtype)
+        return self._native_series.__array__(dtype=dtype, copy=copy)
+
+    def __eq__(self, other: object) -> Self:  # type: ignore[override]
+        return self._from_native_series(self._native_series.__eq__(extract_native(other)))
+
+    def __ne__(self, other: object) -> Self:  # type: ignore[override]
+        return self._from_native_series(self._native_series.__ne__(extract_native(other)))
+
+    def __ge__(self, other: Any) -> Self:
+        return self._from_native_series(self._native_series.__ge__(extract_native(other)))
+
+    def __gt__(self, other: Any) -> Self:
+        return self._from_native_series(self._native_series.__gt__(extract_native(other)))
+
+    def __le__(self, other: Any) -> Self:
+        return self._from_native_series(self._native_series.__le__(extract_native(other)))
+
+    def __lt__(self, other: Any) -> Self:
+        return self._from_native_series(self._native_series.__lt__(extract_native(other)))
+
+    def __and__(self, other: PolarsSeries | bool | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__and__(extract_native(other))
+        )
+
+    def __or__(self, other: PolarsSeries | bool | Any) -> Self:
+        return self._from_native_series(self._native_series.__or__(extract_native(other)))
+
+    def __add__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__add__(extract_native(other))
+        )
+
+    def __radd__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__radd__(extract_native(other))
+        )
+
+    def __sub__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__sub__(extract_native(other))
+        )
+
+    def __rsub__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__rsub__(extract_native(other))
+        )
+
+    def __mul__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__mul__(extract_native(other))
+        )
+
+    def __rmul__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__rmul__(extract_native(other))
+        )
+
+    def __pow__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__pow__(extract_native(other))
+        )
+
+    def __rpow__(self, other: PolarsSeries | Any) -> Self:
+        return self._from_native_series(
+            self._native_series.__rpow__(extract_native(other))
+        )
+
+    def __invert__(self) -> Self:
+        return self._from_native_series(self._native_series.__invert__())
+
+    @property
+    def dt(self) -> PolarsSeriesDateTimeNamespace:
+        return PolarsSeriesDateTimeNamespace(self)
+
+    @property
+    def str(self) -> PolarsSeriesStringNamespace:
+        return PolarsSeriesStringNamespace(self)
+
+    @property
+    def cat(self) -> PolarsSeriesCatNamespace:
+        return PolarsSeriesCatNamespace(self)
+
+
+class PolarsSeriesDateTimeNamespace:
+    def __init__(self, series: PolarsSeries) -> None:
+        self._series = series
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._series._from_native_series(
+                getattr(self._series._native_series.dt, attr)(*args, **kwargs)
+            )
+
+        return func
+
+
+class PolarsSeriesStringNamespace:
+    def __init__(self, series: PolarsSeries) -> None:
+        self._series = series
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._series._from_native_series(
+                getattr(self._series._native_series.str, attr)(*args, **kwargs)
+            )
+
+        return func
+
+
+class PolarsSeriesCatNamespace:
+    def __init__(self, series: PolarsSeries) -> None:
+        self._series = series
+
+    def __getattr__(self, attr: str) -> Any:
+        def func(*args: Any, **kwargs: Any) -> Any:
+            args, kwargs = extract_args_kwargs(args, kwargs)  # type: ignore[assignment]
+            return self._series._from_native_series(
+                getattr(self._series._native_series.cat, attr)(*args, **kwargs)
+            )
+
+        return func
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
new file mode 100644
index 00000000..2dc92b3a
--- /dev/null
+++ b/narwhals/_polars/utils.py
@@ -0,0 +1,111 @@
+from __future__ import annotations
+
+from typing import Any
+
+from narwhals import dtypes
+from narwhals.dependencies import get_polars
+
+
+def extract_native(obj: Any) -> Any:
+    from narwhals._polars.dataframe import PolarsDataFrame
+    from narwhals._polars.dataframe import PolarsLazyFrame
+    from narwhals._polars.expr import PolarsExpr
+    from narwhals._polars.series import PolarsSeries
+
+    if isinstance(obj, (PolarsDataFrame, PolarsLazyFrame)):
+        return obj._native_dataframe
+    if isinstance(obj, PolarsSeries):
+        return obj._native_series
+    if isinstance(obj, PolarsExpr):
+        return obj._native_expr
+    return obj
+
+
+def extract_args_kwargs(args: Any, kwargs: Any) -> tuple[list[Any], dict[str, Any]]:
+    args = [extract_native(arg) for arg in args]
+    kwargs = {k: extract_native(v) for k, v in kwargs.items()}
+    return args, kwargs
+
+
+def translate_dtype(dtype: Any) -> dtypes.DType:
+    pl = get_polars()
+    if dtype == pl.Float64:
+        return dtypes.Float64()
+    if dtype == pl.Float32:
+        return dtypes.Float32()
+    if dtype == pl.Int64:
+        return dtypes.Int64()
+    if dtype == pl.Int32:
+        return dtypes.Int32()
+    if dtype == pl.Int16:
+        return dtypes.Int16()
+    if dtype == pl.Int8:
+        return dtypes.Int8()
+    if dtype == pl.UInt64:
+        return dtypes.UInt64()
+    if dtype == pl.UInt32:
+        return dtypes.UInt32()
+    if dtype == pl.UInt16:
+        return dtypes.UInt16()
+    if dtype == pl.UInt8:
+        return dtypes.UInt8()
+    if dtype == pl.String:
+        return dtypes.String()
+    if dtype == pl.Boolean:
+        return dtypes.Boolean()
+    if dtype == pl.Object:
+        return dtypes.Object()
+    if dtype == pl.Categorical:
+        return dtypes.Categorical()
+    if dtype == pl.Enum:
+        return dtypes.Enum()
+    if dtype == pl.Datetime:
+        return dtypes.Datetime()
+    if dtype == pl.Duration:
+        return dtypes.Duration()
+    if dtype == pl.Date:
+        return dtypes.Date()
+    return dtypes.Unknown()
+
+
+def reverse_translate_dtype(dtype: dtypes.DType | type[dtypes.DType]) -> Any:
+    pl = get_polars()
+    from narwhals import dtypes
+
+    if dtype == dtypes.Float64:
+        return pl.Float64()
+    if dtype == dtypes.Float32:
+        return pl.Float32()
+    if dtype == dtypes.Int64:
+        return pl.Int64()
+    if dtype == dtypes.Int32:
+        return pl.Int32()
+    if dtype == dtypes.Int16:
+        return pl.Int16()
+    if dtype == dtypes.Int8:
+        return pl.Int8()
+    if dtype == dtypes.UInt64:
+        return pl.UInt64()
+    if dtype == dtypes.UInt32:
+        return pl.UInt32()
+    if dtype == dtypes.UInt16:
+        return pl.UInt16()
+    if dtype == dtypes.UInt8:
+        return pl.UInt8()
+    if dtype == dtypes.String:
+        return pl.String()
+    if dtype == dtypes.Boolean:
+        return pl.Boolean()
+    if dtype == dtypes.Object:  # pragma: no cover
+        return pl.Object()
+    if dtype == dtypes.Categorical:
+        return pl.Categorical()
+    if dtype == dtypes.Enum:  # pragma: no cover
+        return pl.Enum()
+    if dtype == dtypes.Datetime:
+        return pl.Datetime()
+    if dtype == dtypes.Duration:
+        return pl.Duration()
+    if dtype == dtypes.Date:
+        return pl.Date()
+    return pl.Unknown()  # pragma: no cover
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 85130aff..f4aa363d 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -13,10 +13,8 @@ from typing import overload
 
 from narwhals.dependencies import get_numpy
 from narwhals.dependencies import get_polars
-from narwhals.dtypes import to_narwhals_dtype
 from narwhals.schema import Schema
 from narwhals.utils import flatten
-from narwhals.utils import validate_same_library
 
 if TYPE_CHECKING:
     from io import BytesIO
@@ -36,29 +34,21 @@ FrameT = TypeVar("FrameT", bound="IntoDataFrame")
 
 class BaseFrame(Generic[FrameT]):
     _compliant_frame: Any
-    _is_polars: bool
-    _backend_version: tuple[int, ...]
     _level: Literal["full", "interchange"]
 
     def __len__(self) -> Any:
         return self._compliant_frame.__len__()
 
     def __native_namespace__(self) -> Any:
-        if self._is_polars:
-            return get_polars()
         return self._compliant_frame.__native_namespace__()
 
     def __narwhals_namespace__(self) -> Any:
-        if self._is_polars:
-            return get_polars()
         return self._compliant_frame.__narwhals_namespace__()
 
     def _from_compliant_dataframe(self, df: Any) -> Self:
         # construct, preserving properties
         return self.__class__(  # type: ignore[call-arg]
             df,
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -90,34 +80,17 @@ class BaseFrame(Generic[FrameT]):
 
     @property
     def schema(self) -> Schema:
-        return Schema(
-            {
-                k: to_narwhals_dtype(v, is_polars=self._is_polars)
-                for k, v in self._compliant_frame.schema.items()
-            }
-        )
+        return Schema(self._compliant_frame.schema.items())
 
     def collect_schema(self) -> Schema:
-        if self._is_polars and self._backend_version < (1, 0, 0):  # pragma: no cover
-            native_schema = self._compliant_frame.schema
-        else:
-            native_schema = dict(self._compliant_frame.collect_schema())
+        native_schema = dict(self._compliant_frame.collect_schema())
 
-        return Schema(
-            {
-                k: to_narwhals_dtype(v, is_polars=self._is_polars)
-                for k, v in native_schema.items()
-            }
-        )
+        return Schema(native_schema)
 
     def pipe(self, function: Callable[[Any], Self], *args: Any, **kwargs: Any) -> Self:
         return function(self, *args, **kwargs)
 
     def with_row_index(self, name: str = "index") -> Self:
-        if self._is_polars and self._backend_version < (0, 20, 4):  # pragma: no cover
-            return self._from_compliant_dataframe(
-                self._compliant_frame.with_row_count(name),
-            )
         return self._from_compliant_dataframe(
             self._compliant_frame.with_row_index(name),
         )
@@ -134,8 +107,6 @@ class BaseFrame(Generic[FrameT]):
     def lazy(self) -> LazyFrame[Any]:
         return LazyFrame(
             self._compliant_frame.lazy(),
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -206,7 +177,6 @@ class BaseFrame(Generic[FrameT]):
             msg = "Can not pass left_on, right_on for cross join"
             raise ValueError(msg)
 
-        validate_same_library([self, other])
         return self._from_compliant_dataframe(
             self._compliant_frame.join(
                 self._extract_compliant(other),
@@ -234,20 +204,14 @@ class DataFrame(BaseFrame[FrameT]):
         self,
         df: Any,
         *,
-        backend_version: tuple[int, ...],
-        is_polars: bool,
         level: Literal["full", "interchange"],
     ) -> None:
-        self._is_polars = is_polars
-        self._backend_version = backend_version
         self._level: Literal["full", "interchange"] = level
         if hasattr(df, "__narwhals_dataframe__"):
             self._compliant_frame: Any = df.__narwhals_dataframe__()
-        elif is_polars and isinstance(df, get_polars().DataFrame):
-            self._compliant_frame = df
-        else:
-            msg = f"Expected Polars DataFrame or an object which implements `__narwhals_dataframe__`, got: {type(df)}"
-            raise TypeError(msg)
+        else:  # pragma: no cover
+            msg = f"Expected an object which implements `__narwhals_dataframe__`, got: {type(df)}"
+            raise AssertionError(msg)
 
     def __array__(self) -> np.ndarray:
         return self._compliant_frame.to_numpy()
@@ -316,10 +280,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.to_pandas()
-            ...     return df
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.to_pandas()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -379,10 +342,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.to_numpy()
-            ...     return df
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.to_numpy()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -414,9 +376,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     return df.shape
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.shape
 
             We can then pass either pandas or Polars to `func`:
 
@@ -449,9 +411,9 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify(eager_only=True)
-            ... def func(df):
-            ...     name = df.columns[0]
-            ...     return df.get_column(name)
+            ... def func(df_any):
+            ...     name = df_any.columns[0]
+            ...     return df_any.get_column(name)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -471,8 +433,6 @@ class DataFrame(BaseFrame[FrameT]):
 
         return Series(
             self._compliant_frame.get_column(name),
-            backend_version=self._backend_version,
-            is_polars=self._is_polars,
             level=self._level,
         )
 
@@ -519,8 +479,8 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify(eager_only=True)
-            ... def func(df):
-            ...     return df["a"]
+            ... def func(df_any):
+            ...     return df_any["a"]
 
             We can then pass either pandas or Polars to `func`:
 
@@ -541,8 +501,6 @@ class DataFrame(BaseFrame[FrameT]):
 
             return Series(
                 self._compliant_frame[item],
-                backend_version=self._backend_version,
-                is_polars=self._is_polars,
                 level=self._level,
             )
 
@@ -589,10 +547,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.to_dict(as_series=False)
-            ...     return df
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.to_dict(as_series=False)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -607,8 +564,6 @@ class DataFrame(BaseFrame[FrameT]):
             return {
                 key: Series(
                     value,
-                    backend_version=self._backend_version,
-                    is_polars=self._is_polars,
                     level=self._level,
                 )
                 for key, value in self._compliant_frame.to_dict(
@@ -632,12 +587,11 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.pipe(
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.pipe(
             ...         lambda _df: _df.select([x for x in _df.columns if len(x) == 1])
             ...     )
-            ...     return nw.to_native(df)
 
             We can then pass either pandas or Polars:
 
@@ -678,10 +632,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.drop_nulls()
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.drop_nulls()
 
             We can then pass either pandas or Polars:
 
@@ -716,10 +669,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_row_index()
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.with_row_index()
 
             We can then pass either pandas or Polars:
 
@@ -761,9 +713,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     return df.schema
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.schema
 
             You can pass either pandas or Polars to `func`:
 
@@ -795,9 +747,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     return df.collect_schema()
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.collect_schema()
 
             You can pass either pandas or Polars to `func`:
 
@@ -827,8 +779,8 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.columns
+            ... def func(df_any):
+            ...     return df_any.columns
 
             We can then pass either pandas or Polars to `func`:
 
@@ -883,9 +835,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any, *, named):
-            ...     df = nw.from_native(df_any)
-            ...     return df.rows(named=named)
+            >>> @nw.narwhalify
+            ... def func(df_any, *, named):
+            ...     return df_any.rows(named=named)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -939,9 +891,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any, *, named):
-            ...     df = nw.from_native(df_any)
-            ...     return df.iter_rows(named=named)
+            >>> @nw.narwhalify
+            ... def func(df_any, *, named):
+            ...     return df_any.iter_rows(named=named)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -995,8 +947,8 @@ class DataFrame(BaseFrame[FrameT]):
             to add it as a new column:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns((nw.col("a") * 2).alias("a*2"))
+            ... def func(df_any):
+            ...     return df_any.with_columns((nw.col("a") * 2).alias("a*2"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1053,8 +1005,8 @@ class DataFrame(BaseFrame[FrameT]):
             column to select that column.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select("foo")
+            ... def func(df_any):
+            ...     return df_any.select("foo")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1078,8 +1030,8 @@ class DataFrame(BaseFrame[FrameT]):
             Multiple columns can be selected by passing a list of column names.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(["foo", "bar"])
+            ... def func(df_any):
+            ...     return df_any.select(["foo", "bar"])
             >>> func(df_pd)
                foo  bar
             0    1    6
@@ -1101,8 +1053,8 @@ class DataFrame(BaseFrame[FrameT]):
             list. Expressions are also accepted.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("foo"), nw.col("bar") + 1)
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("foo"), nw.col("bar") + 1)
             >>> func(df_pd)
                foo  bar
             0    1    7
@@ -1123,8 +1075,8 @@ class DataFrame(BaseFrame[FrameT]):
             Use keyword arguments to easily name your expression inputs.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(threshold=nw.col("foo") * 2)
+            ... def func(df_any):
+            ...     return df_any.select(threshold=nw.col("foo") * 2)
             >>> func(df_pd)
                threshold
             0          2
@@ -1162,8 +1114,8 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.rename({"foo": "apple"})
+            ... def func(df_any):
+            ...     return df_any.rename({"foo": "apple"})
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1209,8 +1161,8 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that gets the first 3 rows.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.head(3)
+            ... def func(df_any):
+            ...     return df_any.head(3)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1257,8 +1209,8 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that gets the last 3 rows.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.tail(3)
+            ... def func(df_any):
+            ...     return df_any.tail(3)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1299,8 +1251,8 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("ham")
+            ... def func(df_any):
+            ...     return df_any.drop("ham")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1324,8 +1276,8 @@ class DataFrame(BaseFrame[FrameT]):
             Use positional arguments to drop multiple columns.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("foo", "ham")
+            ... def func(df_any):
+            ...     return df_any.drop("foo", "ham")
 
             >>> func(df_pd)
                bar
@@ -1368,8 +1320,8 @@ class DataFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.unique(["bar", "ham"])
+            ... def func(df_any):
+            ...     return df_any.unique(["bar", "ham"])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1413,8 +1365,8 @@ class DataFrame(BaseFrame[FrameT]):
             one condition.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter(nw.col("foo") > 1)
+            ... def func(df_any):
+            ...     return df_any.filter(nw.col("foo") > 1)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1436,8 +1388,8 @@ class DataFrame(BaseFrame[FrameT]):
             Filter on multiple conditions, combined with and/or operators:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
+            ... def func(df_any):
+            ...     return df_any.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
             >>> func(df_pd)
                foo  bar ham
             0    1    6   a
@@ -1452,8 +1404,8 @@ class DataFrame(BaseFrame[FrameT]):
             └─────┴─────┴─────┘
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
+            ... def func(df_any):
+            ...     return df_any.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
             >>> func(df_pd)
                foo  bar ham
             0    1    6   a
@@ -1472,8 +1424,8 @@ class DataFrame(BaseFrame[FrameT]):
             Provide multiple filters using `*args` syntax:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     dframe = df.filter(
+            ... def func(df_any):
+            ...     dframe = df_any.filter(
             ...         nw.col("foo") <= 2,
             ...         ~nw.col("ham").is_in(["b", "c"]),
             ...     )
@@ -1519,8 +1471,8 @@ class DataFrame(BaseFrame[FrameT]):
             and call `agg` to compute the grouped sum of another column.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by("a").agg(nw.col("b").sum()).sort("a")
+            ... def func(df_any):
+            ...     return df_any.group_by("a").agg(nw.col("b").sum()).sort("a")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1544,8 +1496,8 @@ class DataFrame(BaseFrame[FrameT]):
             Group by multiple columns by passing a list of column names.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by(["a", "b"]).agg(nw.max("c")).sort("a", "b")
+            ... def func(df_any):
+            ...     return df_any.group_by(["a", "b"]).agg(nw.max("c")).sort("a", "b")
             >>> func(df_pd)
                a  b  c
             0  a  1  5
@@ -1604,8 +1556,8 @@ class DataFrame(BaseFrame[FrameT]):
             columns in different orders
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.sort("c", "a", descending=[False, True])
+            ... def func(df_any):
+            ...     return df_any.sort("c", "a", descending=[False, True])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1679,8 +1631,8 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function in which we join over "ham" column:
 
             >>> @nw.narwhalify
-            ... def join_on_ham(df, other):
-            ...     return df.join(other, left_on="ham", right_on="ham")
+            ... def join_on_ham(df_any, other_any):
+            ...     return df_any.join(other_any, left_on="ham", right_on="ham")
 
             We can now pass either pandas or Polars to the function:
 
@@ -1726,10 +1678,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     duplicated = df.is_duplicated()
-            ...     return nw.to_native(duplicated)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.is_duplicated()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1754,8 +1705,6 @@ class DataFrame(BaseFrame[FrameT]):
 
         return Series(
             self._compliant_frame.is_duplicated(),
-            backend_version=self._backend_version,
-            is_polars=self._is_polars,
             level=self._level,
         )
 
@@ -1771,9 +1720,9 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that filters rows in which "foo"
             values are greater than 10, and then checks if the result is empty or not:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     return df.filter(nw.col("foo") > 10).is_empty()
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.filter(nw.col("foo") > 10).is_empty()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1813,10 +1762,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     unique = df.is_unique()
-            ...     return nw.to_native(unique)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.is_unique()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1841,8 +1789,6 @@ class DataFrame(BaseFrame[FrameT]):
 
         return Series(
             self._compliant_frame.is_unique(),
-            backend_version=self._backend_version,
-            is_polars=self._is_polars,
             level=self._level,
         )
 
@@ -1876,10 +1822,9 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that returns the null count of
             each columns:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     null_counts = df.null_count()
-            ...     return nw.to_native(null_counts)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.null_count()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1918,9 +1863,9 @@ class DataFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function that returns item at given row/column
 
-            >>> def func(df_any, row, column):
-            ...     df = nw.from_native(df_any)
-            ...     return df.item(row, column)
+            >>> @nw.narwhalify
+            ... def func(df_any, row, column):
+            ...     return df_any.item(row, column)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1947,8 +1892,8 @@ class DataFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function in which we clone the DataFrame:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.clone()
+            ... def func(df_any):
+            ...     return df_any.clone()
 
             >>> func(df_pd)
                a  b
@@ -1983,22 +1928,14 @@ class LazyFrame(BaseFrame[FrameT]):
         self,
         df: Any,
         *,
-        is_polars: bool,
-        backend_version: tuple[int, ...],
         level: Literal["full", "interchange"],
     ) -> None:
-        self._is_polars = is_polars
-        self._backend_version = backend_version
         self._level = level
         if hasattr(df, "__narwhals_lazyframe__"):
             self._compliant_frame: Any = df.__narwhals_lazyframe__()
-        elif is_polars and (
-            (pl := get_polars()) is not None and isinstance(df, pl.LazyFrame)
-        ):
-            self._compliant_frame = df
-        else:
+        else:  # pragma: no cover
             msg = f"Expected Polars LazyFrame or an object that implements `__narwhals_lazyframe__`, got: {type(df)}"
-            raise TypeError(msg)
+            raise AssertionError(msg)
 
     def __repr__(self) -> str:  # pragma: no cover
         header = " Narwhals LazyFrame                            "
@@ -2056,8 +1993,6 @@ class LazyFrame(BaseFrame[FrameT]):
         """
         return DataFrame(
             self._compliant_frame.collect(),
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -2076,10 +2011,9 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.pipe(lambda _df: _df.select("a"))
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.pipe(lambda _df: _df.select("a"))
 
             We can then pass either pandas or Polars:
 
@@ -2120,10 +2054,9 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.drop_nulls()
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.drop_nulls()
 
             We can then pass either pandas or Polars:
 
@@ -2156,10 +2089,9 @@ class LazyFrame(BaseFrame[FrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.with_row_index()
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.with_row_index()
 
             We can then pass either pandas or Polars:
 
@@ -2239,8 +2171,8 @@ class LazyFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.columns
+            ... def func(df_any):
+            ...     return df_any.columns
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2291,8 +2223,8 @@ class LazyFrame(BaseFrame[FrameT]):
             to add it as a new column:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns((nw.col("a") * 2).alias("2a"))
+            ... def func(df_any):
+            ...     return df_any.with_columns((nw.col("a") * 2).alias("2a"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2362,8 +2294,8 @@ class LazyFrame(BaseFrame[FrameT]):
             column to select that column.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select("foo")
+            ... def func(df_any):
+            ...     return df_any.select("foo")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2398,8 +2330,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Multiple columns can be selected by passing a list of column names.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(["foo", "bar"])
+            ... def func(df_any):
+            ...     return df_any.select(["foo", "bar"])
             >>> func(df_pd)
                foo  bar
             0    1    6
@@ -2432,8 +2364,8 @@ class LazyFrame(BaseFrame[FrameT]):
             list. Expressions are also accepted.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("foo"), nw.col("bar") + 1)
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("foo"), nw.col("bar") + 1)
             >>> func(df_pd)
                foo  bar
             0    1    7
@@ -2465,8 +2397,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Use keyword arguments to easily name your expression inputs.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(threshold=nw.col("foo") * 2)
+            ... def func(df_any):
+            ...     return df_any.select(threshold=nw.col("foo") * 2)
             >>> func(df_pd)
                threshold
             0          2
@@ -2522,8 +2454,8 @@ class LazyFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.rename({"foo": "apple"})
+            ... def func(df_any):
+            ...     return df_any.rename({"foo": "apple"})
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2568,8 +2500,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that gets the first 3 rows.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.head(3)
+            ... def func(df_any):
+            ...     return df_any.head(3)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2625,8 +2557,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function that gets the last 3 rows.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.tail(3)
+            ... def func(df_any):
+            ...     return df_any.tail(3)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2679,8 +2611,8 @@ class LazyFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("ham")
+            ... def func(df_any):
+            ...     return df_any.drop("ham")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2704,8 +2636,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Use positional arguments to drop multiple columns.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.drop("foo", "ham")
+            ... def func(df_any):
+            ...     return df_any.drop("foo", "ham")
 
             >>> func(df_pd)
                bar
@@ -2752,8 +2684,8 @@ class LazyFrame(BaseFrame[FrameT]):
             We define a library agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.unique(["bar", "ham"])
+            ... def func(df_any):
+            ...     return df_any.unique(["bar", "ham"])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2785,21 +2717,21 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import pandas as pd
             >>> import polars as pl
             >>> import narwhals as nw
-            >>> df = {
+            >>> data = {
             ...     "foo": [1, 2, 3],
             ...     "bar": [6, 7, 8],
             ...     "ham": ["a", "b", "c"],
             ... }
-            >>> df_pd = pd.DataFrame(df)
-            >>> df_pl = pl.DataFrame(df)
-            >>> lf_pl = pl.LazyFrame(df)
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+            >>> lf_pl = pl.LazyFrame(data)
 
             Let's define a dataframe-agnostic function in which we filter on
             one condition.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter(nw.col("foo") > 1)
+            ... def func(df_any):
+            ...     return df_any.filter(nw.col("foo") > 1)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2831,8 +2763,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Filter on multiple conditions:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
+            ... def func(df_any):
+            ...     return df_any.filter((nw.col("foo") < 3) & (nw.col("ham") == "a"))
             >>> func(df_pd)
                foo  bar ham
             0    1    6   a
@@ -2858,8 +2790,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Provide multiple filters using `*args` syntax:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     dframe = df.filter(
+            ... def func(df_any):
+            ...     dframe = df_any.filter(
             ...         nw.col("foo") == 1,
             ...         nw.col("ham") == "a",
             ...     )
@@ -2889,8 +2821,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Filter on an OR condition:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
+            ... def func(df_any):
+            ...     return df_any.filter((nw.col("foo") == 1) | (nw.col("ham") == "c"))
             >>> func(df_pd)
                foo  bar ham
             0    1    6   a
@@ -2947,8 +2879,8 @@ class LazyFrame(BaseFrame[FrameT]):
             and call `agg` to compute the grouped sum of another column.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by("a").agg(nw.col("b").sum()).sort("a")
+            ... def func(df_any):
+            ...     return df_any.group_by("a").agg(nw.col("b").sum()).sort("a")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2983,8 +2915,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Group by multiple columns by passing a list of column names.
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.group_by(["a", "b"]).agg(nw.max("c")).sort(["a", "b"])
+            ... def func(df_any):
+            ...     return df_any.group_by(["a", "b"]).agg(nw.max("c")).sort(["a", "b"])
             >>> func(df_pd)
                a  b  c
             0  a  1  5
@@ -3056,8 +2988,8 @@ class LazyFrame(BaseFrame[FrameT]):
             columns in different orders
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.sort("c", "a", descending=[False, True])
+            ... def func(df_any):
+            ...     return df_any.sort("c", "a", descending=[False, True])
 
             We can then pass either pandas or Polars to `func`:
 
@@ -3131,8 +3063,8 @@ class LazyFrame(BaseFrame[FrameT]):
             Let's define a dataframe-agnostic function in which we join over "ham" column:
 
             >>> @nw.narwhalify
-            ... def join_on_ham(df, other):
-            ...     return df.join(other, left_on="ham", right_on="ham")
+            ... def join_on_ham(df_any, other_any):
+            ...     return df_any.join(other_any, left_on="ham", right_on="ham")
 
             We can now pass either pandas or Polars to the function:
 
@@ -3168,8 +3100,8 @@ class LazyFrame(BaseFrame[FrameT]):
         Let's define a dataframe-agnostic function in which we copy the DataFrame:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.clone()
+        ... def func(df_any):
+        ...     return df_any.clone()
 
         >>> func(df_pd)
            a  b
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index 66fffdf3..9b56d614 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -3,7 +3,6 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 
-from narwhals.dependencies import get_polars
 from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
@@ -134,47 +133,3 @@ def translate_dtype(plx: Any, dtype: DType) -> Any:
         return plx.Date
     msg = f"Unknown dtype: {dtype}"  # pragma: no cover
     raise AssertionError(msg)
-
-
-def to_narwhals_dtype(dtype: Any, *, is_polars: bool) -> DType:
-    if not is_polars:
-        return dtype  # type: ignore[no-any-return]
-    pl = get_polars()
-
-    if dtype == pl.Float64:
-        return Float64()
-    if dtype == pl.Float32:
-        return Float32()
-    if dtype == pl.Int64:
-        return Int64()
-    if dtype == pl.Int32:
-        return Int32()
-    if dtype == pl.Int16:
-        return Int16()
-    if dtype == pl.Int8:
-        return Int8()
-    if dtype == pl.UInt64:
-        return UInt64()
-    if dtype == pl.UInt32:
-        return UInt32()
-    if dtype == pl.UInt16:
-        return UInt16()
-    if dtype == pl.UInt8:
-        return UInt8()
-    if dtype == pl.String:
-        return String()
-    if dtype == pl.Boolean:
-        return Boolean()
-    if dtype == pl.Object:
-        return Object()
-    if dtype == pl.Categorical:
-        return Categorical()
-    if dtype == pl.Enum:
-        return Enum()
-    if dtype == pl.Datetime:
-        return Datetime()
-    if dtype == pl.Duration:
-        return Duration()
-    if dtype == pl.Date:
-        return Date()
-    return Unknown()
diff --git a/narwhals/expression.py b/narwhals/expression.py
index a6af772a..d5297e10 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -7,11 +7,9 @@ from typing import Iterable
 from typing import Literal
 
 from narwhals.dependencies import get_numpy
-from narwhals.dependencies import get_polars
 from narwhals.dtypes import DType
 from narwhals.dtypes import translate_dtype
 from narwhals.utils import flatten
-from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -19,7 +17,7 @@ if TYPE_CHECKING:
     from narwhals.typing import IntoExpr
 
 
-def extract_native(expr: Expr, other: Any) -> Any:
+def extract_compliant(expr: Expr, other: Any) -> Any:
     from narwhals.series import Series
 
     if isinstance(other, Expr):
@@ -132,122 +130,122 @@ class Expr:
     # --- binary ---
     def __eq__(self, other: object) -> Self:  # type: ignore[override]
         return self.__class__(
-            lambda plx: self._call(plx).__eq__(extract_native(plx, other))
+            lambda plx: self._call(plx).__eq__(extract_compliant(plx, other))
         )
 
     def __ne__(self, other: object) -> Self:  # type: ignore[override]
         return self.__class__(
-            lambda plx: self._call(plx).__ne__(extract_native(plx, other))
+            lambda plx: self._call(plx).__ne__(extract_compliant(plx, other))
         )
 
     def __and__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__and__(extract_native(plx, other))
+            lambda plx: self._call(plx).__and__(extract_compliant(plx, other))
         )
 
     def __rand__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rand__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rand__(extract_compliant(plx, other))
         )
 
     def __or__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__or__(extract_native(plx, other))
+            lambda plx: self._call(plx).__or__(extract_compliant(plx, other))
         )
 
     def __ror__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__ror__(extract_native(plx, other))
+            lambda plx: self._call(plx).__ror__(extract_compliant(plx, other))
         )
 
     def __add__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__add__(extract_native(plx, other))
+            lambda plx: self._call(plx).__add__(extract_compliant(plx, other))
         )
 
     def __radd__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__radd__(extract_native(plx, other))
+            lambda plx: self._call(plx).__radd__(extract_compliant(plx, other))
         )
 
     def __sub__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__sub__(extract_native(plx, other))
+            lambda plx: self._call(plx).__sub__(extract_compliant(plx, other))
         )
 
     def __rsub__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rsub__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rsub__(extract_compliant(plx, other))
         )
 
     def __truediv__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__truediv__(extract_native(plx, other))
+            lambda plx: self._call(plx).__truediv__(extract_compliant(plx, other))
         )
 
     def __rtruediv__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rtruediv__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rtruediv__(extract_compliant(plx, other))
         )
 
     def __mul__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__mul__(extract_native(plx, other))
+            lambda plx: self._call(plx).__mul__(extract_compliant(plx, other))
         )
 
     def __rmul__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rmul__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rmul__(extract_compliant(plx, other))
         )
 
     def __le__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__le__(extract_native(plx, other))
+            lambda plx: self._call(plx).__le__(extract_compliant(plx, other))
         )
 
     def __lt__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__lt__(extract_native(plx, other))
+            lambda plx: self._call(plx).__lt__(extract_compliant(plx, other))
         )
 
     def __gt__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__gt__(extract_native(plx, other))
+            lambda plx: self._call(plx).__gt__(extract_compliant(plx, other))
         )
 
     def __ge__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__ge__(extract_native(plx, other))
+            lambda plx: self._call(plx).__ge__(extract_compliant(plx, other))
         )
 
     def __pow__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__pow__(extract_native(plx, other))
+            lambda plx: self._call(plx).__pow__(extract_compliant(plx, other))
         )
 
     def __rpow__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rpow__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rpow__(extract_compliant(plx, other))
         )
 
     def __floordiv__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__floordiv__(extract_native(plx, other))
+            lambda plx: self._call(plx).__floordiv__(extract_compliant(plx, other))
         )
 
     def __rfloordiv__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rfloordiv__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rfloordiv__(extract_compliant(plx, other))
         )
 
     def __mod__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__mod__(extract_native(plx, other))
+            lambda plx: self._call(plx).__mod__(extract_compliant(plx, other))
         )
 
     def __rmod__(self, other: Any) -> Self:
         return self.__class__(
-            lambda plx: self._call(plx).__rmod__(extract_native(plx, other))
+            lambda plx: self._call(plx).__rmod__(extract_compliant(plx, other))
         )
 
     # --- unary ---
@@ -268,8 +266,8 @@ class Expr:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").any())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").any())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -302,8 +300,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").all())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").all())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -336,8 +334,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").mean())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").mean())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -374,8 +372,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").std(ddof=0))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").std(ddof=0))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -409,8 +407,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").sum())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").sum())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -441,9 +439,10 @@ class Expr:
             >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [4, 3]})
 
             Let's define a dataframe-agnostic function:
+
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.min("a", "b"))
+            ... def func(df_any):
+            ...     return df_any.select(nw.min("a", "b"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -477,8 +476,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.max("a", "b"))
+            ... def func(df_any):
+            ...     return df_any.select(nw.max("a", "b"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -511,8 +510,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().count())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().count())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -545,8 +544,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").n_unique())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").n_unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -579,8 +578,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").unique())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -618,8 +617,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").abs())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").abs())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -654,8 +653,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").cum_sum())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a", "b").cum_sum())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -705,8 +704,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(a_diff=nw.col("a").diff())
+            ... def func(df_any):
+            ...     return df_any.select(a_diff=nw.col("a").diff())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -756,8 +755,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(a_shift=nw.col("a").shift(n=1))
+            ... def func(df_any):
+            ...     return df_any.select(a_shift=nw.col("a").shift(n=1))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -802,8 +801,8 @@ class Expr:
             Let's define dataframe-agnostic functions:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").sort())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").sort())
 
             >>> def func_descend(df_any):
             ...     df = nw.from_native(df_any)
@@ -876,8 +875,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").is_between(2, 4, "right"))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").is_between(2, 4, "right"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -923,8 +922,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(b=nw.col("a").is_in([1, 2]))
+            ... def func(df_any):
+            ...     return df_any.with_columns(b=nw.col("a").is_in([1, 2]))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -968,8 +967,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(
+            ... def func(df_any):
+            ...     return df_any.select(
             ...         nw.col("a").filter(nw.col("a") > 4),
             ...         nw.col("b").filter(nw.col("b") < 13),
             ...     )
@@ -995,7 +994,7 @@ class Expr:
         """
         return self.__class__(
             lambda plx: self._call(plx).filter(
-                *[extract_native(plx, pred) for pred in flatten(predicates)]
+                *[extract_compliant(plx, pred) for pred in flatten(predicates)]
             )
         )
 
@@ -1021,8 +1020,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         a_is_null=nw.col("a").is_null(), b_is_null=nw.col("b").is_null()
             ...     )
 
@@ -1074,8 +1073,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(nw.col("a", "b").fill_null(0))
+            ... def func(df_any):
+            ...     return df_any.with_columns(nw.col("a", "b").fill_null(0))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1123,8 +1122,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").drop_nulls())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").drop_nulls())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1178,8 +1177,10 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").sample(fraction=1.0, with_replacement=True))
+            ... def func(df_any):
+            ...     return df_any.select(
+            ...         nw.col("a").sample(fraction=1.0, with_replacement=True)
+            ...     )
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1226,8 +1227,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(a_min_per_group=nw.col("a").min().over("b"))
+            ... def func(df_any):
+            ...     return df_any.with_columns(a_min_per_group=nw.col("a").min().over("b"))
 
             We can then pass either pandas or Polars:
 
@@ -1265,8 +1266,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().is_duplicated())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().is_duplicated())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1306,8 +1307,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().is_unique())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().is_unique())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1352,8 +1353,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().null_count())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().null_count())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1387,8 +1388,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().is_first_distinct())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().is_first_distinct())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1427,8 +1428,8 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.all().is_last_distinct())
+            ... def func(df_any):
+            ...     return df_any.select(nw.all().is_last_distinct())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1480,8 +1481,10 @@ class Expr:
             Let's define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a", "b").quantile(0.5, interpolation="linear"))
+            ... def func(df_any):
+            ...     return df_any.select(
+            ...         nw.col("a", "b").quantile(0.5, interpolation="linear")
+            ...     )
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1522,8 +1525,8 @@ class Expr:
             Let's define a dataframe-agnostic function that returns the first 3 rows:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").head(3))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").head(3))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1566,8 +1569,8 @@ class Expr:
             Let's define a dataframe-agnostic function that returns the last 3 rows:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").tail(3))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").tail(3))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1618,8 +1621,8 @@ class Expr:
             Let's define a dataframe-agnostic function that rounds to the first decimal:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").round(1))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").round(1))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1660,8 +1663,8 @@ class Expr:
             Let's define a dataframe-agnostic function that computes the len over different values of "b" column:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(
+            ... def func(df_any):
+            ...     return df_any.select(
             ...         nw.col("a").filter(nw.col("b") == 1).len().alias("a1"),
             ...         nw.col("a").filter(nw.col("b") == 2).len().alias("a2"),
             ...     )
@@ -1718,8 +1721,8 @@ class ExprCatNamespace:
             from column 'fruits':
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("fruits").cat.get_categories())
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("fruits").cat.get_categories())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1765,8 +1768,10 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(has_prefix=nw.col("fruits").str.starts_with("app"))
+            ... def func(df_any):
+            ...     return df_any.with_columns(
+            ...         has_prefix=nw.col("fruits").str.starts_with("app")
+            ...     )
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1810,8 +1815,10 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(has_suffix=nw.col("fruits").str.ends_with("ngo"))
+            ... def func(df_any):
+            ...     return df_any.with_columns(
+            ...         has_suffix=nw.col("fruits").str.ends_with("ngo")
+            ...     )
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1857,8 +1864,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         default_match=nw.col("pets").str.contains("parrot|Dove"),
             ...         case_insensitive_match=nw.col("pets").str.contains("(?i)parrot|Dove"),
             ...         literal_match=nw.col("pets").str.contains(
@@ -1914,8 +1921,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(s_sliced=nw.col("s").str.slice(4, length=3))
+            ... def func(df_any):
+            ...     return df_any.with_columns(s_sliced=nw.col("s").str.slice(4, length=3))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -1942,8 +1949,8 @@ class ExprStringNamespace:
             Using negative indexes:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(s_sliced=nw.col("s").str.slice(-3))
+            ... def func(df_any):
+            ...     return df_any.with_columns(s_sliced=nw.col("s").str.slice(-3))
 
             >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
                          s s_sliced
@@ -1990,8 +1997,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(lyrics_head=nw.col("lyrics").str.head())
+            ... def func(df_any):
+            ...     return df_any.with_columns(lyrics_head=nw.col("lyrics").str.head())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2038,8 +2045,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(lyrics_tail=nw.col("lyrics").str.tail())
+            ... def func(df_any):
+            ...     return df_any.with_columns(lyrics_tail=nw.col("lyrics").str.tail())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2090,8 +2097,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").str.to_datetime(format="%Y-%m-%d"))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").str.to_datetime(format="%Y-%m-%d"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2134,8 +2141,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(upper_col=nw.col("fruits").str.to_uppercase())
+            ... def func(df_any):
+            ...     return df_any.with_columns(upper_col=nw.col("fruits").str.to_uppercase())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2175,8 +2182,8 @@ class ExprStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(lower_col=nw.col("fruits").str.to_lowercase())
+            ... def func(df_any):
+            ...     return df_any.with_columns(lower_col=nw.col("fruits").str.to_lowercase())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2229,8 +2236,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(nw.col("datetime").dt.year().alias("year"))
+            ... def func(df_any):
+            ...     return df_any.with_columns(nw.col("datetime").dt.year().alias("year"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2277,8 +2284,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.year().alias("year"),
             ...         nw.col("datetime").dt.month().alias("month"),
             ...     )
@@ -2328,8 +2335,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.year().alias("year"),
             ...         nw.col("datetime").dt.month().alias("month"),
             ...         nw.col("datetime").dt.day().alias("day"),
@@ -2380,8 +2387,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(nw.col("datetime").dt.hour().alias("hour"))
+            ... def func(df_any):
+            ...     return df_any.with_columns(nw.col("datetime").dt.hour().alias("hour"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2428,8 +2435,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...     )
@@ -2477,8 +2484,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
@@ -2527,8 +2534,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
@@ -2578,8 +2585,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
@@ -2629,8 +2636,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         nw.col("datetime").dt.hour().alias("hour"),
             ...         nw.col("datetime").dt.minute().alias("minute"),
             ...         nw.col("datetime").dt.second().alias("second"),
@@ -2674,8 +2681,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(a_ordinal_day=nw.col("a").dt.ordinal_day())
+            ... def func(df_any):
+            ...     return df_any.with_columns(a_ordinal_day=nw.col("a").dt.ordinal_day())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2717,8 +2724,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(a_total_minutes=nw.col("a").dt.total_minutes())
+            ... def func(df_any):
+            ...     return df_any.with_columns(a_total_minutes=nw.col("a").dt.total_minutes())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2760,8 +2767,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(a_total_seconds=nw.col("a").dt.total_seconds())
+            ... def func(df_any):
+            ...     return df_any.with_columns(a_total_seconds=nw.col("a").dt.total_seconds())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2808,8 +2815,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         a_total_milliseconds=nw.col("a").dt.total_milliseconds()
             ...     )
 
@@ -2860,8 +2867,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         a_total_microseconds=nw.col("a").dt.total_microseconds()
             ...     )
 
@@ -2909,8 +2916,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(
+            ... def func(df_any):
+            ...     return df_any.with_columns(
             ...         a_diff_total_nanoseconds=nw.col("a").diff().dt.total_nanoseconds()
             ...     )
 
@@ -2986,8 +2993,8 @@ class ExprDateTimeNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.select(nw.col("a").dt.to_string("%Y/%m/%d %H:%M:%S"))
+            ... def func(df_any):
+            ...     return df_any.select(nw.col("a").dt.to_string("%Y/%m/%d %H:%M:%S"))
 
             We can then pass either pandas or Polars to `func`:
 
@@ -3031,8 +3038,8 @@ def col(*names: str | Iterable[str]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.col("a") * nw.col("b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.col("a") * nw.col("b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -3051,7 +3058,11 @@ def col(*names: str | Iterable[str]) -> Expr:
         │ 8   │
         └─────┘
     """
-    return Expr(lambda plx: plx.col(*names))
+
+    def func(plx: Any) -> Any:
+        return plx.col(*names)
+
+    return Expr(func)
 
 
 def all() -> Expr:
@@ -3068,8 +3079,8 @@ def all() -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.all() * 2)
+        ... def func(df_any):
+        ...     return df_any.select(nw.all() * 2)
 
         We can then pass either pandas or Polars to `func`:
 
@@ -3107,8 +3118,8 @@ def len() -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.len())
+        ... def func(df_any):
+        ...     return df_any.select(nw.len())
 
         We can then pass either pandas or Polars to `func`:
 
@@ -3127,12 +3138,6 @@ def len() -> Expr:
     """
 
     def func(plx: Any) -> Any:
-        if (
-            (pl := get_polars()) is not None
-            and plx is pl
-            and parse_version(pl.__version__) <= (0, 20, 4)
-        ):  # pragma: no cover
-            return plx.count().alias("len")
         return plx.len()
 
     return Expr(func)
@@ -3158,8 +3163,8 @@ def sum(*columns: str) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.sum("a"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.sum("a"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -3199,10 +3204,9 @@ def mean(*columns: str) -> Expr:
 
         We define a dataframe agnostic function:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(nw.mean("a"))
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(nw.mean("a"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -3243,8 +3247,8 @@ def min(*columns: str) -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.min("b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.min("b"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -3284,8 +3288,8 @@ def max(*columns: str) -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.max("a"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.max("a"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -3322,8 +3326,8 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.sum_horizontal("a", "b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.sum_horizontal("a", "b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -3345,7 +3349,9 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         └─────┘
     """
     return Expr(
-        lambda plx: plx.sum_horizontal([extract_native(plx, v) for v in flatten(exprs)])
+        lambda plx: plx.sum_horizontal(
+            *[extract_compliant(plx, v) for v in flatten(exprs)]
+        )
     )
 
 
@@ -3373,8 +3379,8 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select("a", "b", all=nw.all_horizontal("a", "b"))
+        ... def func(df_any):
+        ...     return df_any.select("a", "b", all=nw.all_horizontal("a", "b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -3403,7 +3409,9 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         └───────┴───────┴───────┘
     """
     return Expr(
-        lambda plx: plx.all_horizontal([extract_native(plx, v) for v in flatten(exprs)])
+        lambda plx: plx.all_horizontal(
+            *[extract_compliant(plx, v) for v in flatten(exprs)]
+        )
     )
 
 
@@ -3425,8 +3433,8 @@ def lit(value: Any, dtype: DType | None = None) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.with_columns(nw.lit(3).alias("b"))
+        ... def func(df_any):
+        ...     return df_any.with_columns(nw.lit(3).alias("b"))
 
         We can then pass either pandas or polars to `func`:
 
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 24d299be..da6ebdf6 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -12,7 +12,6 @@ from typing import Union
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.utils import validate_laziness
-from narwhals.utils import validate_same_library
 
 # Missing type parameters for generic type "DataFrame"
 # However, trying to provide one results in mypy still complaining...
@@ -35,7 +34,6 @@ def concat(
         msg = "No items to concatenate"
         raise ValueError(msg)
     items = list(items)
-    validate_same_library(items)
     validate_laziness(items)
     first_item = items[0]
     plx = first_item.__narwhals_namespace__()
diff --git a/narwhals/group_by.py b/narwhals/group_by.py
index ed5f6281..69995cba 100644
--- a/narwhals/group_by.py
+++ b/narwhals/group_by.py
@@ -62,15 +62,13 @@ class GroupBy(Generic[DataFrameT]):
 
             We define library agnostic functions:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.group_by("a").agg(nw.col("b").sum()).sort("a")
-            ...     return nw.to_native(df)
-
-            >>> def func_mult_col(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.group_by("a", "b").agg(nw.sum("c")).sort("a", "b")
-            ...     return nw.to_native(df)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.group_by("a").agg(nw.col("b").sum()).sort("a")
+
+            >>> @nw.narwhalify
+            ... def func_mult_col(df_any):
+            ...     return df_any.group_by("a", "b").agg(nw.sum("c")).sort("a", "b")
 
             We can then pass either pandas or Polars to `func` and `func_mult_col`:
 
diff --git a/narwhals/selectors.py b/narwhals/selectors.py
index c6f45b94..6c512591 100644
--- a/narwhals/selectors.py
+++ b/narwhals/selectors.py
@@ -30,10 +30,9 @@ def by_dtype(*dtypes: Any) -> Expr:
         Let's define a dataframe-agnostic function to select int64 and float64
         dtypes and multiplies each value by 2:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.by_dtype(nw.Int64, nw.Float64) * 2)
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.by_dtype(nw.Int64, nw.Float64) * 2)
 
         We can then pass either pandas or Polars dataframes:
 
@@ -76,10 +75,9 @@ def numeric() -> Expr:
         Let's define a dataframe-agnostic function to select numeric
         dtypes and multiplies each value by 2:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.numeric() * 2)
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.numeric() * 2)
 
         We can then pass either pandas or Polars dataframes:
 
@@ -118,10 +116,9 @@ def boolean() -> Expr:
         Let's define a dataframe-agnostic function to select boolean
         dtypes:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.boolean())
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.boolean())
 
         We can then pass either pandas or Polars dataframes:
 
@@ -160,10 +157,9 @@ def string() -> Expr:
         Let's define a dataframe-agnostic function to select string
         dtypes:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.string())
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.string())
 
         We can then pass either pandas or Polars dataframes:
 
@@ -202,10 +198,9 @@ def categorical() -> Expr:
         Let's define a dataframe-agnostic function to select string
         dtypes:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.categorical())
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.categorical())
 
         We can then pass either pandas or Polars dataframes:
 
@@ -244,10 +239,9 @@ def all() -> Expr:
         Let's define a dataframe-agnostic function to select string
         dtypes:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(ncs.all())
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(ncs.all())
 
         We can then pass either pandas or Polars dataframes:
 
diff --git a/narwhals/series.py b/narwhals/series.py
index 41664540..aa1c2524 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -4,8 +4,6 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Literal
 
-from narwhals.dependencies import get_polars
-from narwhals.dtypes import to_narwhals_dtype
 from narwhals.dtypes import translate_dtype
 
 if TYPE_CHECKING:
@@ -30,26 +28,16 @@ class Series:
         self,
         series: Any,
         *,
-        backend_version: tuple[int, ...],
-        is_polars: bool,
         level: Literal["full", "interchange"],
     ) -> None:
-        self._is_polars = is_polars
-        self._backend_version = backend_version
         self._level = level
         if hasattr(series, "__narwhals_series__"):
             self._compliant_series = series.__narwhals_series__()
-        elif is_polars and (
-            (pl := get_polars()) is not None and isinstance(series, pl.Series)
-        ):
-            self._compliant_series = series
-        else:
+        else:  # pragma: no cover
             msg = f"Expected Polars Series or an object which implements `__narwhals_series__`, got: {type(series)}."
-            raise TypeError(msg)
+            raise AssertionError(msg)
 
     def __array__(self, dtype: Any = None, copy: bool | None = None) -> np.ndarray:
-        if self._is_polars and self._backend_version < (0, 20, 29):  # pragma: no cover
-            return self._compliant_series.__array__(dtype=dtype)
         return self._compliant_series.__array__(dtype=dtype, copy=copy)
 
     def __getitem__(self, idx: int | slice) -> Any:
@@ -58,13 +46,9 @@ class Series:
         return self._from_compliant_series(self._compliant_series[idx])
 
     def __native_namespace__(self) -> Any:
-        if self._is_polars:
-            return get_polars()
         return self._compliant_series.__native_namespace__()
 
     def __narwhals_namespace__(self) -> Any:
-        if self._is_polars:
-            return get_polars()
         return self._compliant_series.__narwhals_namespace__()
 
     @property
@@ -105,8 +89,6 @@ class Series:
     def _from_compliant_series(self, series: Any) -> Self:
         return self.__class__(
             series,
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -182,7 +164,7 @@ class Series:
             >>> func(s_pl)
             Int64
         """
-        return to_narwhals_dtype(self._compliant_series.dtype, is_polars=self._is_polars)
+        return self._compliant_series.dtype
 
     @property
     def name(self) -> str:
@@ -299,8 +281,6 @@ class Series:
 
         return DataFrame(
             self._compliant_series.to_frame(),
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -1678,8 +1658,6 @@ class Series:
 
         return DataFrame(
             self._compliant_series.value_counts(sort=sort, parallel=parallel),
-            is_polars=self._is_polars,
-            backend_version=self._backend_version,
             level=self._level,
         )
 
@@ -2022,8 +2000,8 @@ class SeriesStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(series):
-            ...     return series.str.starts_with("app")
+            ... def func(s_any):
+            ...     return s_any.str.starts_with("app")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2064,8 +2042,8 @@ class SeriesStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(series):
-            ...     return series.str.ends_with("ngo")
+            ... def func(s_any):
+            ...     return s_any.str.ends_with("ngo")
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2323,8 +2301,8 @@ class SeriesStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(upper_col=nw.col("fruits").str.to_uppercase())
+            ... def func(df_any):
+            ...     return df_any.with_columns(upper_col=nw.col("fruits").str.to_uppercase())
 
             We can then pass either pandas or Polars to `func`:
 
@@ -2366,8 +2344,8 @@ class SeriesStringNamespace:
             We define a dataframe-agnostic function:
 
             >>> @nw.narwhalify
-            ... def func(df):
-            ...     return df.with_columns(lower_col=nw.col("fruits").str.to_lowercase())
+            ... def func(df_any):
+            ...     return df_any.with_columns(lower_col=nw.col("fruits").str.to_lowercase())
 
             We can then pass either pandas or Polars to `func`:
 
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 45468d78..89f8ba44 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -149,10 +149,9 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
             We define a library agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     df = df.to_dict(as_series=False)
-            ...     return df
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.to_dict(as_series=False)
 
             We can then pass either pandas or Polars to `func`:
 
@@ -188,10 +187,9 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     duplicated = df.is_duplicated()
-            ...     return nw.to_native(duplicated)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.is_duplicated()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -237,10 +235,9 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
 
             Let's define a dataframe-agnostic function:
 
-            >>> def func(df_any):
-            ...     df = nw.from_native(df_any)
-            ...     unique = df.is_unique()
-            ...     return nw.to_native(unique)
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.is_unique()
 
             We can then pass either pandas or Polars to `func`:
 
@@ -466,22 +463,16 @@ def _stableify(
     if isinstance(obj, NwDataFrame):
         return DataFrame(
             obj._compliant_frame,
-            is_polars=obj._is_polars,
-            backend_version=obj._backend_version,
             level=obj._level,
         )
     if isinstance(obj, NwLazyFrame):
         return LazyFrame(
             obj._compliant_frame,
-            is_polars=obj._is_polars,
-            backend_version=obj._backend_version,
             level=obj._level,
         )
     if isinstance(obj, NwSeries):
         return Series(
             obj._compliant_series,
-            is_polars=obj._is_polars,
-            backend_version=obj._backend_version,
             level=obj._level,
         )
     if isinstance(obj, NwExpr):
@@ -756,8 +747,8 @@ def narwhalify(
 
 
     @nw.narwhalify
-    def func(df):
-        return df.group_by("a").agg(nw.col("b").sum())
+    def func(df_any):
+        return df_any.group_by("a").agg(nw.col("b").sum())
     ```
 
     You can also pass in extra arguments, e.g.
@@ -842,8 +833,8 @@ def all() -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.all() * 2)
+        ... def func(df_any):
+        ...     return df_any.select(nw.all() * 2)
 
         We can then pass either pandas or Polars to `func`:
 
@@ -884,8 +875,8 @@ def col(*names: str | Iterable[str]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.col("a") * nw.col("b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.col("a") * nw.col("b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -921,8 +912,8 @@ def len() -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.len())
+        ... def func(df_any):
+        ...     return df_any.select(nw.len())
 
         We can then pass either pandas or Polars to `func`:
 
@@ -960,8 +951,8 @@ def lit(value: Any, dtype: DType | None = None) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.with_columns(nw.lit(3).alias("b"))
+        ... def func(df_any):
+        ...     return df_any.with_columns(nw.lit(3).alias("b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -1004,8 +995,8 @@ def min(*columns: str) -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.min("b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.min("b"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -1045,8 +1036,8 @@ def max(*columns: str) -> Expr:
         Let's define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.max("a"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.max("a"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -1085,10 +1076,9 @@ def mean(*columns: str) -> Expr:
 
         We define a dataframe agnostic function:
 
-        >>> def func(df_any):
-        ...     df = nw.from_native(df_any)
-        ...     df = df.select(nw.mean("a"))
-        ...     return nw.to_native(df)
+        >>> @nw.narwhalify
+        ... def func(df_any):
+        ...     return df_any.select(nw.mean("a"))
 
         We can then pass either pandas or Polars to `func`:
 
@@ -1128,8 +1118,8 @@ def sum(*columns: str) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.sum("a"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.sum("a"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -1166,8 +1156,8 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select(nw.sum_horizontal("a", "b"))
+        ... def func(df_any):
+        ...     return df_any.select(nw.sum_horizontal("a", "b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -1215,8 +1205,8 @@ def all_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
         We define a dataframe-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(df):
-        ...     return df.select("a", "b", all=nw.all_horizontal("a", "b"))
+        ... def func(df_any):
+        ...     return df_any.select("a", "b", all=nw.all_horizontal("a", "b"))
 
         We can then pass either pandas or polars to `func`:
 
@@ -1274,8 +1264,8 @@ def is_ordered_categorical(series: Series) -> bool:
         Let's define a library-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(s):
-        ...     return nw.is_ordered_categorical(s)
+        ... def func(s_any):
+        ...     return nw.is_ordered_categorical(s_any)
 
         Then, we can pass any supported library to `func`:
 
diff --git a/narwhals/translate.py b/narwhals/translate.py
index e13fb9b3..24481d20 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -57,17 +57,9 @@ def to_native(
     from narwhals.series import Series
 
     if isinstance(narwhals_object, BaseFrame):
-        return (
-            narwhals_object._compliant_frame
-            if narwhals_object._is_polars
-            else narwhals_object._compliant_frame._native_dataframe
-        )
+        return narwhals_object._compliant_frame._native_dataframe
     if isinstance(narwhals_object, Series):
-        return (
-            narwhals_object._compliant_series
-            if narwhals_object._is_polars
-            else narwhals_object._compliant_series._native_series
-        )
+        return narwhals_object._compliant_series._native_series
 
     if strict:
         msg = f"Expected Narwhals object, got {type(narwhals_object)}."
@@ -292,6 +284,9 @@ def from_native(  # noqa: PLR0915
     from narwhals._pandas_like.dataframe import PandasLikeDataFrame
     from narwhals._pandas_like.series import PandasLikeSeries
     from narwhals._pandas_like.utils import Implementation
+    from narwhals._polars.dataframe import PolarsDataFrame
+    from narwhals._polars.dataframe import PolarsLazyFrame
+    from narwhals._polars.series import PolarsSeries
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
     from narwhals.series import Series
@@ -317,9 +312,7 @@ def from_native(  # noqa: PLR0915
             msg = "Cannot only use `series_only` with polars.DataFrame"
             raise TypeError(msg)
         return DataFrame(
-            native_object,
-            is_polars=True,
-            backend_version=parse_version(pl.__version__),
+            PolarsDataFrame(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
     elif (pl := get_polars()) is not None and isinstance(native_object, pl.LazyFrame):
@@ -330,9 +323,7 @@ def from_native(  # noqa: PLR0915
             msg = "Cannot only use `eager_only` or `eager_or_interchange_only` with polars.LazyFrame"
             raise TypeError(msg)
         return LazyFrame(
-            native_object,
-            is_polars=True,
-            backend_version=parse_version(pl.__version__),
+            PolarsLazyFrame(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
     elif (pd := get_pandas()) is not None and isinstance(native_object, pd.DataFrame):
@@ -345,8 +336,6 @@ def from_native(  # noqa: PLR0915
                 backend_version=parse_version(pd.__version__),
                 implementation=Implementation.PANDAS,
             ),
-            is_polars=False,
-            backend_version=parse_version(pd.__version__),
             level="full",
         )
     elif (mpd := get_modin()) is not None and isinstance(native_object, mpd.DataFrame):
@@ -359,8 +348,6 @@ def from_native(  # noqa: PLR0915
                 implementation=Implementation.MODIN,
                 backend_version=parse_version(mpd.__version__),
             ),
-            is_polars=False,
-            backend_version=parse_version(mpd.__version__),
             level="full",
         )
     elif (cudf := get_cudf()) is not None and isinstance(  # pragma: no cover
@@ -375,8 +362,6 @@ def from_native(  # noqa: PLR0915
                 implementation=Implementation.CUDF,
                 backend_version=parse_version(cudf.__version__),
             ),
-            is_polars=False,
-            backend_version=parse_version(cudf.__version__),
             level="full",
         )
     elif (pa := get_pyarrow()) is not None and isinstance(native_object, pa.Table):
@@ -385,8 +370,6 @@ def from_native(  # noqa: PLR0915
             raise TypeError(msg)
         return DataFrame(
             ArrowDataFrame(native_object, backend_version=parse_version(pa.__version__)),
-            is_polars=False,
-            backend_version=parse_version(pa.__version__),
             level="full",
         )
     elif hasattr(native_object, "__dataframe__"):
@@ -399,8 +382,6 @@ def from_native(  # noqa: PLR0915
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return DataFrame(
             InterchangeFrame(native_object.__dataframe__()),
-            is_polars=False,
-            backend_version=(0,),
             level="interchange",
         )
     elif hasattr(native_object, "__narwhals_dataframe__"):
@@ -410,8 +391,6 @@ def from_native(  # noqa: PLR0915
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return DataFrame(
             native_object.__narwhals_dataframe__(),
-            is_polars=False,
-            backend_version=(0,),
             level="full",
         )
     elif hasattr(native_object, "__narwhals_lazyframe__"):
@@ -424,8 +403,6 @@ def from_native(  # noqa: PLR0915
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return LazyFrame(
             native_object.__narwhals_lazyframe__(),
-            is_polars=False,
-            backend_version=(0,),
             level="full",
         )
     elif (pl := get_polars()) is not None and isinstance(native_object, pl.Series):
@@ -433,9 +410,7 @@ def from_native(  # noqa: PLR0915
             msg = "Please set `allow_series=True`"
             raise TypeError(msg)
         return Series(
-            native_object,
-            is_polars=True,
-            backend_version=parse_version(pl.__version__),
+            PolarsSeries(native_object, backend_version=parse_version(pl.__version__)),
             level="full",
         )
     elif (pd := get_pandas()) is not None and isinstance(native_object, pd.Series):
@@ -448,8 +423,6 @@ def from_native(  # noqa: PLR0915
                 implementation=Implementation.PANDAS,
                 backend_version=parse_version(pd.__version__),
             ),
-            is_polars=False,
-            backend_version=parse_version(pd.__version__),
             level="full",
         )
     elif (mpd := get_modin()) is not None and isinstance(native_object, mpd.Series):
@@ -462,8 +435,6 @@ def from_native(  # noqa: PLR0915
                 implementation=Implementation.MODIN,
                 backend_version=parse_version(mpd.__version__),
             ),
-            is_polars=False,
-            backend_version=parse_version(mpd.__version__),
             level="full",
         )
     elif (cudf := get_cudf()) is not None and isinstance(
@@ -478,8 +449,6 @@ def from_native(  # noqa: PLR0915
                 implementation=Implementation.CUDF,
                 backend_version=parse_version(cudf.__version__),
             ),
-            is_polars=False,
-            backend_version=parse_version(cudf.__version__),
             level="full",
         )
     elif (pa := get_pyarrow()) is not None and isinstance(native_object, pa.ChunkedArray):
@@ -490,8 +459,6 @@ def from_native(  # noqa: PLR0915
             ArrowSeries(
                 native_object, backend_version=parse_version(pa.__version__), name=""
             ),
-            is_polars=False,
-            backend_version=parse_version(pa.__version__),
             level="full",
         )
     elif hasattr(native_object, "__narwhals_series__"):
@@ -501,8 +468,6 @@ def from_native(  # noqa: PLR0915
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return Series(
             native_object.__narwhals_series__(),
-            backend_version=(0,),
-            is_polars=False,
             level="full",
         )
     elif strict:
@@ -570,8 +535,8 @@ def narwhalify(
 
 
     @nw.narwhalify
-    def func(df):
-        return df.group_by("a").agg(nw.col("b").sum())
+    def func(df_any):
+        return df_any.group_by("a").agg(nw.col("b").sum())
     ```
 
     You can also pass in extra arguments, e.g.
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 1f6a9074..c586dfcf 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import re
+import secrets
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterable
@@ -85,17 +86,6 @@ def isinstance_or_issubclass(obj: Any, cls: Any) -> bool:
     return isinstance(obj, cls) or issubclass(obj, cls)
 
 
-def validate_same_library(items: Iterable[Any]) -> None:
-    if all(item._is_polars for item in items):
-        return
-    if all(hasattr(item._compliant_frame, "_implementation") for item in items) and (
-        len({item._compliant_frame._implementation for item in items}) == 1
-    ):
-        return
-    msg = "Cross-library comparisons aren't supported"
-    raise NotImplementedError(msg)
-
-
 def validate_laziness(items: Iterable[Any]) -> None:
     from narwhals.dataframe import DataFrame
     from narwhals.dataframe import LazyFrame
@@ -296,8 +286,8 @@ def is_ordered_categorical(series: Series) -> bool:
         Let's define a library-agnostic function:
 
         >>> @nw.narwhalify
-        ... def func(s):
-        ...     return nw.is_ordered_categorical(s)
+        ... def func(s_any):
+        ...     return nw.is_ordered_categorical(s_any)
 
         Then, we can pass any supported library to `func`:
 
@@ -336,3 +326,31 @@ def is_ordered_categorical(series: Series) -> bool:
         return native_series.type.ordered  # type: ignore[no-any-return]
     # If it doesn't match any of the above, let's just play it safe and return False.
     return False  # pragma: no cover
+
+
+def generate_unique_token(n_bytes: int, columns: list[str]) -> str:  # pragma: no cover
+    """Generates a unique token of specified n_bytes that is not present in the given list of columns.
+
+    Arguments:
+        n_bytes : The number of bytes to generate for the token.
+        columns : The list of columns to check for uniqueness.
+
+    Returns:
+        A unique token that is not present in the given list of columns.
+
+    Raises:
+        AssertionError: If a unique token cannot be generated after 100 attempts.
+    """
+    counter = 0
+    while True:
+        token = secrets.token_hex(n_bytes)
+        if token not in columns:
+            return token
+
+        counter += 1
+        if counter > 100:
+            msg = (
+                "Internal Error: Narwhals was not able to generate a column name to perform given "
+                "join operation"
+            )
+            raise AssertionError(msg)
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index 2d1c36b8..86cc1513 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -37,9 +37,7 @@ if extra := set(documented).difference(top_level_functions):
 
 top_level_functions = [
     i
-    for i in nw.DataFrame(
-        pl.DataFrame(), is_polars=True, backend_version=(0,), level="full"
-    ).__dir__()
+    for i in nw.from_native(pl.DataFrame()).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
 with open("docs/api-reference/dataframe.md") as fd:
@@ -60,9 +58,7 @@ if extra := set(documented).difference(top_level_functions).difference({"__getit
 
 top_level_functions = [
     i
-    for i in nw.LazyFrame(
-        pl.LazyFrame(), is_polars=True, backend_version=(0,), level="full"
-    ).__dir__()
+    for i in nw.from_native(pl.LazyFrame()).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
 with open("docs/api-reference/lazyframe.md") as fd:
@@ -83,9 +79,7 @@ if extra := set(documented).difference(top_level_functions):
 
 top_level_functions = [
     i
-    for i in nw.Series(
-        pl.Series(), backend_version=(1,), is_polars=True, level="full"
-    ).__dir__()
+    for i in nw.from_native(pl.Series(), series_only=True).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
 with open("docs/api-reference/series.md") as fd:
@@ -141,9 +135,7 @@ if extra := set(documented).difference(top_level_functions):
 expr = [i for i in nw.Expr(lambda: 0).__dir__() if not i[0].isupper() and i[0] != "_"]
 series = [
     i
-    for i in nw.Series(
-        pl.Series(), backend_version=(1,), is_polars=True, level="full"
-    ).__dir__()
+    for i in nw.from_native(pl.Series(), series_only=True).__dir__()
     if not i[0].isupper() and i[0] != "_"
 ]
 
diff --git a/utils/check_backend_completeness.py b/utils/check_backend_completeness.py
index 90506dc4..1c74f68a 100644
--- a/utils/check_backend_completeness.py
+++ b/utils/check_backend_completeness.py
@@ -18,7 +18,6 @@ MISSING = [
     "DataFrame.pipe",
     "DataFrame.unique",
     "Series.drop_nulls",
-    "Series.fill_null",
     "Series.from_iterable",
     "Series.is_between",
     "Series.is_duplicated",
@@ -26,7 +25,6 @@ MISSING = [
     "Series.is_last_distinct",
     "Series.is_sorted",
     "Series.is_unique",
-    "Series.n_unique",
     "Series.quantile",
     "Series.round",
     "Series.shift",
@@ -79,8 +77,6 @@ if __name__ == "__main__":
     df_pa = ArrowDataFrame(MockDataFrame({"a": [1, 2, 3]}), backend_version=(13, 0))
     df_nw = nw.DataFrame(
         MockDataFrame({"a": [1, 2, 3]}),
-        is_polars=True,
-        backend_version=(1,),
         level="full",
     )
     pa_methods = [f"DataFrame.{x}" for x in df_pa.__dir__() if not x.startswith("_")]


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 3ae02c65..871fcadc 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -9,7 +9,7 @@ jobs:
   altair:
     strategy:
       matrix:
-        python-version: ["3.11"]
+        python-version: ["3.12"]
         os: [ubuntu-latest]
 
     runs-on: ${{ matrix.os }}
@@ -27,7 +27,10 @@ jobs:
             ~\AppData\Local\pip\Cache
           key: ${{ runner.os }}-build-${{ matrix.python-version }}
       - name: clone-altair
-        run: git clone https://github.com/vega/altair.git
+        run: |
+            git clone https://github.com/vega/altair.git --depth=1
+            cd altair
+            git log
       - name: install-basics
         run: python -m pip install --upgrade tox virtualenv setuptools pip
       - name: install-altair-dev
@@ -70,7 +73,7 @@ jobs:
             ~\AppData\Local\pip\Cache
           key: ${{ runner.os }}-build-${{ matrix.python-version }}
       - name: clone-scikit-lego
-        run: git clone https://github.com/koaning/scikit-lego.git
+        run: git clone https://github.com/koaning/scikit-lego.git --depth 1
       - name: install-basics
         run: python -m pip install --upgrade tox virtualenv setuptools pip
       - name: install-scikit-lego-dev
diff --git a/tests/expr_and_series/fill_null_test.py b/tests/expr_and_series/fill_null_test.py
index 5cffcddd..dc9cc738 100644
--- a/tests/expr_and_series/fill_null_test.py
+++ b/tests/expr_and_series/fill_null_test.py
@@ -1,7 +1,5 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
@@ -12,10 +10,7 @@ data = {
 }
 
 
-def test_fill_null(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_fill_null(constructor: Any) -> None:
     df = nw.from_native(constructor(data), eager_only=True)
 
     result = df.with_columns(nw.col("a", "b", "c").fill_null(99))
diff --git a/tests/expr_and_series/n_unique_test.py b/tests/expr_and_series/n_unique_test.py
index 291a0a43..98dd0a85 100644
--- a/tests/expr_and_series/n_unique_test.py
+++ b/tests/expr_and_series/n_unique_test.py
@@ -1,7 +1,5 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
@@ -11,10 +9,7 @@ data = {
 }
 
 
-def test_n_unique(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_n_unique(constructor: Any) -> None:
     df = nw.from_native(constructor(data), eager_only=True)
     result = df.select(nw.all().n_unique())
     expected = {
diff --git a/tests/expr_and_series/str/contains_test.py b/tests/expr_and_series/str/contains_test.py
index 32e45de0..831aa82d 100644
--- a/tests/expr_and_series/str/contains_test.py
+++ b/tests/expr_and_series/str/contains_test.py
@@ -2,7 +2,6 @@ from typing import Any
 
 import pandas as pd
 import polars as pl
-import pytest
 
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
@@ -13,9 +12,8 @@ df_pandas = pd.DataFrame(data)
 df_polars = pl.DataFrame(data)
 
 
-@pytest.mark.parametrize("df_any", [df_pandas, df_polars])
-def test_contains(df_any: Any) -> None:
-    df = nw.from_native(df_any, eager_only=True)
+def test_contains(constructor: Any) -> None:
+    df = nw.from_native(constructor(data), eager_only=True)
     result = df.with_columns(
         case_insensitive_match=nw.col("pets").str.contains("(?i)parrot|Dove")
     )
diff --git a/tests/expr_and_series/str/head_test.py b/tests/expr_and_series/str/head_test.py
index 9fe2638f..9deb2862 100644
--- a/tests/expr_and_series/str/head_test.py
+++ b/tests/expr_and_series/str/head_test.py
@@ -1,16 +1,12 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": ["foo", "bars"]}
 
 
-def test_str_head(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
+def test_str_head(constructor: Any) -> None:
     df = nw.from_native(constructor(data), eager_only=True)
     result = df.select(nw.col("a").str.head(3))
     expected = {
diff --git a/tests/expr_and_series/str/slice_test.py b/tests/expr_and_series/str/slice_test.py
index 946f4e35..e3831952 100644
--- a/tests/expr_and_series/str/slice_test.py
+++ b/tests/expr_and_series/str/slice_test.py
@@ -15,11 +15,8 @@ data = {"a": ["fdas", "edfas"]}
     [(1, 2, {"a": ["da", "df"]}), (-2, None, {"a": ["as", "as"]})],
 )
 def test_str_slice(
-    request: Any, constructor: Any, offset: int, length: int | None, expected: Any
+    constructor: Any, offset: int, length: int | None, expected: Any
 ) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     df = nw.from_native(constructor(data), eager_only=True)
     result_frame = df.select(nw.col("a").str.slice(offset, length))
     compare_dicts(result_frame, expected)
diff --git a/tests/expr_and_series/str/starts_with_ends_with_test.py b/tests/expr_and_series/str/starts_with_ends_with_test.py
index ee477461..9216f2e6 100644
--- a/tests/expr_and_series/str/starts_with_ends_with_test.py
+++ b/tests/expr_and_series/str/starts_with_ends_with_test.py
@@ -2,8 +2,6 @@ from __future__ import annotations
 
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 
 # Don't move this into typechecking block, for coverage
@@ -13,10 +11,7 @@ from tests.utils import compare_dicts
 data = {"a": ["fdas", "edfas"]}
 
 
-def test_ends_with(request: Any, constructor_with_lazy: Any) -> None:
-    if "pyarrow_table" in str(constructor_with_lazy):
-        request.applymarker(pytest.mark.xfail)
-
+def test_ends_with(constructor_with_lazy: Any) -> None:
     df = nw.from_native(constructor_with_lazy(data)).lazy()
     result = df.select(nw.col("a").str.ends_with("das"))
     expected = {
@@ -31,10 +26,7 @@ def test_ends_with(request: Any, constructor_with_lazy: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_starts_with(request: Any, constructor_with_lazy: Any) -> None:
-    if "pyarrow_table" in str(constructor_with_lazy):
-        request.applymarker(pytest.mark.xfail)
-
+def test_starts_with(constructor_with_lazy: Any) -> None:
     df = nw.from_native(constructor_with_lazy(data)).lazy()
     result = df.select(nw.col("a").str.starts_with("fda"))
     expected = {
diff --git a/tests/expr_and_series/str/tail_test.py b/tests/expr_and_series/str/tail_test.py
index b7c187e1..f116e445 100644
--- a/tests/expr_and_series/str/tail_test.py
+++ b/tests/expr_and_series/str/tail_test.py
@@ -1,17 +1,12 @@
 from typing import Any
 
-import pytest
-
 import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": ["foo", "bars"]}
 
 
-def test_str_tail(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_str_tail(constructor: Any) -> None:
     df = nw.from_native(constructor(data), eager_only=True)
     expected = {"a": ["foo", "ars"]}
 
diff --git a/tests/expr_and_series/str/to_datetime_test.py b/tests/expr_and_series/str/to_datetime_test.py
index 62c438f8..ea248bde 100644
--- a/tests/expr_and_series/str/to_datetime_test.py
+++ b/tests/expr_and_series/str/to_datetime_test.py
@@ -1,19 +1,15 @@
 from datetime import datetime
 from typing import Any
 
-import pandas as pd
-import polars as pl
-import pytest
-
 import narwhals.stable.v1 as nw
 
-df_pandas = pd.DataFrame({"a": ["2020-01-01T12:34:56"]})
-df_polars = pl.DataFrame({"a": ["2020-01-01T12:34:56"]})
+data = {"a": ["2020-01-01T12:34:56"]}
 
 
-@pytest.mark.parametrize("df_any", [df_pandas, df_polars])
-def test_to_datetime(df_any: Any) -> None:
-    result = nw.from_native(df_any, eager_only=True).select(
-        b=nw.col("a").str.to_datetime(format="%Y-%m-%dT%H:%M:%S")
-    )["b"][0]
+def test_to_datetime(constructor: Any) -> None:
+    result = (
+        nw.from_native(constructor(data), eager_only=True)
+        .select(b=nw.col("a").str.to_datetime(format="%Y-%m-%dT%H:%M:%S"))
+        .item(row=0, column="b")
+    )
     assert result == datetime(2020, 1, 1, 12, 34, 56)
diff --git a/tests/expr_and_series/test_dt.py b/tests/expr_and_series/test_dt.py
index eaa7b8bf..1b7399a2 100644
--- a/tests/expr_and_series/test_dt.py
+++ b/tests/expr_and_series/test_dt.py
@@ -139,6 +139,7 @@ def test_duration_micro_nano(
     parse_version(pd.__version__) < parse_version("2.0.0"),
     reason="pyarrow dtype not available",
 )
+@pytest.mark.slow()
 def test_ordinal_day(dates: datetime) -> None:
     result_pd = nw.from_native(pd.Series([dates]), series_only=True).dt.ordinal_day()[0]
     result_pdms = nw.from_native(
@@ -168,6 +169,7 @@ def test_ordinal_day(dates: datetime) -> None:
     parse_version(pd.__version__) < parse_version("2.2.0"),
     reason="pyarrow dtype not available",
 )
+@pytest.mark.slow()
 def test_total_minutes(timedeltas: timedelta) -> None:
     result_pd = nw.from_native(
         pd.Series([timedeltas]), series_only=True
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index 9ba11991..d8ab380c 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -15,7 +15,9 @@ def test_inner_join_two_keys(constructor: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor(data), eager_only=True)
     df_right = df
-    result = df.join(df_right, left_on=["a", "b"], right_on=["a", "b"], how="inner")
+    result = df.lazy().join(
+        df_right.lazy(), left_on=["a", "b"], right_on=["a", "b"], how="inner"
+    )
     expected = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9], "z_right": [7.0, 8, 9]}
     compare_dicts(result, expected)
 
@@ -35,15 +37,11 @@ def test_inner_join_single_key(constructor: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_cross_join(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_cross_join(constructor: Any) -> None:
     data = {"a": [1, 3, 2]}
     df = nw.from_native(constructor(data))
-    result = df.join(df, how="cross")  # type: ignore[arg-type]
-
-    expected = {"a": [1, 1, 1, 3, 3, 3, 2, 2, 2], "a_right": [1, 3, 2, 1, 3, 2, 1, 3, 2]}
+    result = df.join(df, how="cross").sort("a", "a_right")  # type: ignore[arg-type]
+    expected = {"a": [1, 1, 1, 2, 2, 2, 3, 3, 3], "a_right": [1, 2, 3, 1, 2, 3, 1, 2, 3]}
     compare_dicts(result, expected)
 
     with pytest.raises(ValueError, match="Can not pass left_on, right_on for cross join"):
@@ -69,15 +67,11 @@ def test_cross_join_non_pandas() -> None:
     ],
 )
 def test_anti_join(
-    request: Any,
     constructor: Any,
     join_key: list[str],
     filter_expr: nw.Expr,
     expected: dict[str, list[Any]],
 ) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor(data))
     other = df.filter(filter_expr)
@@ -94,15 +88,11 @@ def test_anti_join(
     ],
 )
 def test_semi_join(
-    request: Any,
     constructor: Any,
     join_key: list[str],
     filter_expr: nw.Expr,
     expected: dict[str, list[Any]],
 ) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor(data))
     other = df.filter(filter_expr)
@@ -125,10 +115,7 @@ def test_join_not_implemented(constructor: Any, how: str) -> None:
 
 
 @pytest.mark.filterwarnings("ignore:the default coalesce behavior")
-def test_left_join(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_left_join(constructor: Any) -> None:
     data_left = {"a": [1.0, 2, 3], "b": [4.0, 5, 6]}
     data_right = {"a": [1.0, 2, 3], "c": [4.0, 5, 7]}
     df_left = nw.from_native(constructor(data_left), eager_only=True)
@@ -141,10 +128,7 @@ def test_left_join(request: Any, constructor: Any) -> None:
 
 
 @pytest.mark.filterwarnings("ignore: the default coalesce behavior")
-def test_left_join_multiple_column(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_left_join_multiple_column(constructor: Any) -> None:
     data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
     data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
     df_left = nw.from_native(constructor(data_left), eager_only=True)
@@ -155,12 +139,9 @@ def test_left_join_multiple_column(request: Any, constructor: Any) -> None:
 
 
 @pytest.mark.filterwarnings("ignore: the default coalesce behavior")
-def test_left_join_overlapping_column(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
-    data_left = {"a": [1, 2, 3], "b": [4, 5, 6], "d": [1, 4, 2]}
-    data_right = {"a": [1, 2, 3], "c": [4, 5, 6], "d": [1, 4, 2]}
+def test_left_join_overlapping_column(constructor: Any) -> None:
+    data_left = {"a": [1.0, 2, 3], "b": [4.0, 5, 6], "d": [1.0, 4, 2]}
+    data_right = {"a": [1.0, 2, 3], "c": [4.0, 5, 6], "d": [1.0, 4, 2]}
     df_left = nw.from_native(constructor(data_left), eager_only=True)
     df_right = nw.from_native(constructor(data_right), eager_only=True)
     result = df_left.join(df_right, left_on="b", right_on="c", how="left")
diff --git a/tests/frame/schema_test.py b/tests/frame/schema_test.py
index aff89209..37a9d8be 100644
--- a/tests/frame/schema_test.py
+++ b/tests/frame/schema_test.py
@@ -114,7 +114,8 @@ def test_dtypes() -> None:
         "r": nw.Enum,
     }
 
-    assert df_from_pl.schema == df_from_pl.collect_schema() == expected
+    assert df_from_pl.schema == df_from_pl.collect_schema()
+    assert df_from_pl.schema == expected
     assert {name: df_from_pl[name].dtype for name in df_from_pl.columns} == expected
 
     # pandas/pyarrow only have categorical, not enum
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 03efae10..bf7f619f 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -264,27 +264,6 @@ def test_reindex(df_raw: Any) -> None:
         nw.to_native(df.with_columns(nw.all() + nw.all()))
 
 
-@pytest.mark.parametrize(
-    ("c_left", "c_right"),
-    [(pd.DataFrame, pl.DataFrame), (pa.table, pd.DataFrame)],
-)
-def test_library(c_left: Any, c_right: Any) -> None:
-    df_left = nw.from_native(c_left(data)).lazy()
-    df_right = nw.from_native(c_right(data)).lazy()
-    with pytest.raises(
-        NotImplementedError, match="Cross-library comparisons aren't supported"
-    ):
-        nw.concat([df_left, df_right], how="horizontal")
-    with pytest.raises(
-        NotImplementedError, match="Cross-library comparisons aren't supported"
-    ):
-        nw.concat([df_left, df_right], how="vertical")
-    with pytest.raises(
-        NotImplementedError, match="Cross-library comparisons aren't supported"
-    ):
-        df_left.join(df_right, left_on=["a"], right_on=["a"], how="inner")
-
-
 @pytest.mark.parametrize(
     ("row", "column", "expected"),
     [(0, 2, 7), (1, "z", 8)],
diff --git a/tests/hypothesis/test_join.py b/tests/hypothesis/test_join.py
index d5db53e3..850caa1a 100644
--- a/tests/hypothesis/test_join.py
+++ b/tests/hypothesis/test_join.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import pandas as pd
 import polars as pl
+import pyarrow as pa
 import pytest
 from hypothesis import assume
 from hypothesis import given
@@ -163,3 +164,18 @@ def test_left_join(  # pragma: no cover
         )
     ).select(pl.all().fill_null(float("nan")))
     compare_dicts(result_pd.to_dict(as_series=False), result_pl.to_dict(as_series=False))
+    # For PyArrow, insert an extra sort, as the order of rows isn't guaranteed
+    result_pa = (
+        nw.from_native(pa.table(data_left), eager_only=True)
+        .join(
+            nw.from_native(pa.table(data_right), eager_only=True),
+            how="left",
+            left_on=left_key,
+            right_on=right_key,
+        )
+        .select(nw.all().cast(nw.Float64).fill_null(float("nan")))
+        .pipe(lambda df: df.sort(df.columns))
+    )
+    compare_dicts(
+        result_pa, result_pd.pipe(lambda df: df.sort(df.columns)).to_dict(as_series=False)
+    )
diff --git a/tests/test_selectors.py b/tests/test_selectors.py
index 3ab5c5e0..41c721cb 100644
--- a/tests/test_selectors.py
+++ b/tests/test_selectors.py
@@ -3,7 +3,7 @@ from __future__ import annotations
 from typing import Any
 
 import pandas as pd
-import polars as pl
+import pyarrow as pa
 import pytest
 
 import narwhals.stable.v1 as nw
@@ -13,6 +13,7 @@ from narwhals.selectors import by_dtype
 from narwhals.selectors import categorical
 from narwhals.selectors import numeric
 from narwhals.selectors import string
+from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
 data = {
@@ -23,54 +24,43 @@ data = {
 }
 
 
-def test_selectors(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_selectors(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
-    result = nw.to_native(df.select(by_dtype([nw.Int64, nw.Float64]) + 1))
+    result = df.select(by_dtype([nw.Int64, nw.Float64]) + 1)
     expected = {"a": [2, 2, 3], "c": [5.1, 6.0, 7.0]}
     compare_dicts(result, expected)
 
 
-def test_numeric(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_numeric(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
-    result = nw.to_native(df.select(numeric() + 1))
+    result = df.select(numeric() + 1)
     expected = {"a": [2, 2, 3], "c": [5.1, 6.0, 7.0]}
     compare_dicts(result, expected)
 
 
-def test_boolean(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_boolean(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
-    result = nw.to_native(df.select(boolean()))
+    result = df.select(boolean())
     expected = {"d": [True, False, True]}
     compare_dicts(result, expected)
 
 
-def test_string(request: Any, constructor: Any) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
+def test_string(constructor: Any) -> None:
     df = nw.from_native(constructor(data))
-    result = nw.to_native(df.select(string()))
+    result = df.select(string())
     expected = {"b": ["a", "b", "c"]}
     compare_dicts(result, expected)
 
 
-def test_categorical() -> None:
-    df = nw.from_native(pd.DataFrame(data).astype({"b": "category"}))
-    result = nw.to_native(df.select(categorical()))
-    expected = {"b": ["a", "b", "c"]}
-    compare_dicts(result, expected)
-    df = nw.from_native(pl.DataFrame(data, schema_overrides={"b": pl.Categorical}))
-    result = nw.to_native(df.select(categorical()))
+def test_categorical(request: Any, constructor: Any) -> None:
+    if "pyarrow_table_constructor" in str(constructor) and parse_version(
+        pa.__version__
+    ) <= (15,):  # pragma: no cover
+        request.applymarker(pytest.mark.xfail)
     expected = {"b": ["a", "b", "c"]}
+
+    df = nw.from_native(constructor(data)).with_columns(nw.col("b").cast(nw.Categorical))
+    result = df.select(categorical())
     compare_dicts(result, expected)
 
 
@@ -89,18 +79,16 @@ def test_categorical() -> None:
     ],
 )
 def test_set_ops(
-    request: Any, constructor: Any, selector: nw.selectors.Selector, expected: list[str]
+    constructor: Any, selector: nw.selectors.Selector, expected: list[str]
 ) -> None:
-    if "pyarrow_table" in str(constructor):
-        request.applymarker(pytest.mark.xfail)
-
     df = nw.from_native(constructor(data))
     result = df.select(selector).columns
     assert sorted(result) == expected
 
 
-def test_set_ops_invalid() -> None:
-    df = nw.from_native(pd.DataFrame(data))
+@pytest.mark.parametrize("invalid_constructor", [pd.DataFrame, pa.table])
+def test_set_ops_invalid(invalid_constructor: Any) -> None:
+    df = nw.from_native(invalid_constructor(data))
     with pytest.raises(NotImplementedError):
         df.select(1 - numeric())
     with pytest.raises(NotImplementedError):
diff --git a/tests/translate/from_native_test.py b/tests/translate/from_native_test.py
index 7e5b67b2..191ad49b 100644
--- a/tests/translate/from_native_test.py
+++ b/tests/translate/from_native_test.py
@@ -153,118 +153,6 @@ def test_pandas_like_validate() -> None:
         nw.from_native(df)
 
 
-@pytest.mark.parametrize(
-    ("series", "is_polars", "context"),
-    [
-        (MockSeries(), False, does_not_raise()),
-        (MockSeries(), True, does_not_raise()),
-        (series_pl, True, does_not_raise()),
-        (
-            series_pd,
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars Series or an object which implements `__narwhals_series__`",
-            ),
-        ),
-        (
-            MockDataFrame(),
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars Series or an object which implements `__narwhals_series__`",
-            ),
-        ),
-    ],
-)
-def test_init_series(series: Any, is_polars: Any, context: Any) -> None:
-    with context:
-        result = nw.Series(
-            series, is_polars=is_polars, backend_version=(1, 2, 3), level="full"
-        )
-        assert isinstance(result, nw.Series)
-
-
-@pytest.mark.parametrize(
-    ("dframe", "is_polars", "context"),
-    [
-        (MockDataFrame(), False, does_not_raise()),
-        (MockDataFrame(), True, does_not_raise()),
-        (df_pl, True, does_not_raise()),
-        (
-            df_pd,
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars DataFrame or an object which implements `__narwhals_dataframe__`",
-            ),
-        ),
-        (
-            MockLazyFrame(),
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars DataFrame or an object which implements `__narwhals_dataframe__`",
-            ),
-        ),
-        (
-            MockSeries(),
-            True,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars DataFrame or an object which implements `__narwhals_dataframe__`",
-            ),
-        ),
-    ],
-)
-def test_init_eager(dframe: Any, is_polars: Any, context: Any) -> None:
-    with context:
-        result = nw.DataFrame(
-            dframe, is_polars=is_polars, backend_version=(1, 2, 3), level="full"
-        )  # type: ignore[var-annotated]
-        assert isinstance(result, nw.DataFrame)
-
-
-@pytest.mark.parametrize(
-    ("dframe", "is_polars", "context"),
-    [
-        (MockLazyFrame(), False, does_not_raise()),
-        (MockLazyFrame(), True, does_not_raise()),
-        (lf_pl, True, does_not_raise()),
-        (
-            df_pd,
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars LazyFrame or an object that implements `__narwhals_lazyframe__`",
-            ),
-        ),
-        (
-            MockDataFrame(),
-            False,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars LazyFrame or an object that implements `__narwhals_lazyframe__`",
-            ),
-        ),
-        (
-            MockSeries(),
-            True,
-            pytest.raises(
-                TypeError,
-                match="Expected Polars LazyFrame or an object that implements `__narwhals_lazyframe__`",
-            ),
-        ),
-    ],
-)
-def test_init_lazy(dframe: Any, is_polars: Any, context: Any) -> None:
-    with context:
-        result = nw.LazyFrame(
-            dframe, is_polars=is_polars, backend_version=(1, 2, 3), level="full"
-        )  # type: ignore[var-annotated]
-        assert isinstance(result, nw.LazyFrame)
-
-
 def test_init_already_narwhals() -> None:
     df = nw.from_native(pl.DataFrame({"a": [1, 2, 3]}))
     result = nw.from_native(df)