Repository: narwhals-dev/narwhals

Issue:
RFC Perfect backwards compatibility policy
Curious to hear thoughts on this one - especially from @baggiponte 

Narwhals follows a strict subset of the Polars API. What happens if/when some part of this subset gets changed in Polars itself?

For example:
- pre 1.0, Polars coalesces overlapping columns in left joins by default
- post 1.0, it won't by default

So, what do we do in Narwhals? I'd suggest allowing people to specify which API version they want to follow in `pyproject.toml`, e.g.:
```toml
[tool.narwhals]
api_version = "0.20"
```

Then:
- if somebody specifies `api_version = "0.20"` in pyproject.toml, then we promise to keep giving them the pre 1.0 behaviour indefinitely (coalesce by default in left joins)
- if somebody specifies `api_version = "1.0"` in pyproject.toml (or if they don't specify it), then we give them the post 1.0 Polars behaviour (don't coalesce by default in left joins)

The idea is that if you implement your library using Narwhals, then so long as you specify an `api_version` in `pyproject.toml`, then your code should preserve the same behaviour indefinitely. You may occasionally need to bump your minimum Narwhals package version (so we can deal with working around upstream changes in pandas/Polars/etc), but you should never need to rewrite your dataframe logic, it'll be perfectly backwards-compatible.

---

Very keen to also get @FBruzzesi and @koaning 's take on this, as consumers. Would you appreciate / be open to specifying a Narwhals `api_version` in your `pyproject.toml`, in exchange for the promise that the Narwhals API you use won't change?

---

So long as we stick to fundamental dataframe operations, and a strict subset of Polars (rather than trying to reimplement _everything_...) then I think this should be doable!

Discussion:
MarcoGorelli: let's do this! 🚀 🎸 🍾 

Patch:
diff --git a/README.md b/README.md
index cde74ad8..fb652dfb 100644
--- a/README.md
+++ b/README.md
@@ -20,13 +20,15 @@ Seamlessly support all, without depending on any!
 
 - ✅ **Just use** a subset of **the Polars API**, no need to learn anything new
 - ✅ **Zero dependencies**, Narwhals only uses what
-  the user passes in, so you can keep your library lightweight
+  the user passes in so your library can stay lightweight
 - ✅ Separate **lazy** and eager APIs, use **expressions**
 - ✅ Support pandas' complicated type system and index, without
   either getting in the way
 - ✅ **100% branch coverage**, tested against pandas and Polars nightly builds
 - ✅ **Negligible overhead**, see [overhead](https://narwhals-dev.github.io/narwhals/overhead/)
 - ✅ Let your IDE help you thanks to **full static typing**, see [typing](https://narwhals-dev.github.io/narwhals/typing/)
+- ✅ **Perfect backwards compatibility policy**,
+  see [stable api](https://narwhals-dev.github.io/narwhals/backcompat/) for how to opt-in
 
 ## Used by / integrates with
 
diff --git a/docs/backcompat.md b/docs/backcompat.md
new file mode 100644
index 00000000..c8e50a8d
--- /dev/null
+++ b/docs/backcompat.md
@@ -0,0 +1,92 @@
+# Perfect backwards compatibility policy
+
+Narwhals is primarily aimed at library maintainers rather than end users. As such,
+we need to take stability and backwards compatibility extra-seriously. Our policy is:
+
+- If you write code using `import narwhals.stable.v1 as nw`, then we promise to
+  never change or remove any public function you're using.
+- If we need to make a backwards-incompatible change, it will be pushed into
+  `narwhals.stable.v2`, leaving `narwhals.stable.v1` unaffected.
+- We will maintain `narwhals.stable.v1` indefinitely, even as `narwhals.stable.v2` and other
+  stable APIs come out. For example, Narwhals version 1.0.0 will offer
+  `narwhals.stable.v1`, whereas Narwhals 2.0.0 will offer both `narwhals.stable.v1` and
+  `narwhals.stable.v2`.
+
+Like this, we enable different packages to be on different Narwhals stable APIs, and for
+end-users to use all of them in the same project without conflicts nor
+incompatibilities.
+
+## Background
+
+Ever upgraded a package, only to find that it breaks all your tests because of an intentional
+API change? Did you end up having to litter your code with statements such as the following?
+
+```python
+if parse_version(pdx.__version__) < parse_version('1.3.0'):
+    df = df.brewbeer()
+elif parse_version('1.3.0') <= parse_version(pdx.__version__) < parse_version('1.5.0'):
+    df = df.brew_beer()
+else:
+    df = df.brew_drink('beer')
+```
+
+Now imagine multiplying that complexity over all the dataframe libraries you want to support...
+
+Narwhals offers a simple solution, inspired by Rust editions.
+
+## Narwhals' Stable API
+
+Narwhals implements a subset of the Polars API. What will Narwhals do if/when Polars makes
+a backwards-incompatible change? Would you need to update your Narwhals code?
+
+To understand the solution, let's go through an example. Suppose that, hypothetically, in Polars 2.0,
+`polars.Expr.cum_sum` was renamed to `polars.Expr.cumulative_sum`. In Narwhals, we
+have `narwhals.Expr.cum_sum`. Does this mean that Narwhals will also rename its method,
+and deprecate the old one? The answer is...no!
+
+Narwhals offers a `stable` namespace, which allows you to write your code once and forget about
+it. That is to say, if you write your code like this:
+
+```python
+import narwhals.stable.v1 as nw
+from narwhals.typing import FrameT
+
+@nw.narwhalify
+def func(df: FrameT) -> FrameT:
+    return df.with_columns(nw.col('a').cum_sum())
+```
+
+then we, in Narwhals, promise that your code will keep working, even in newer versions of Polars
+after they have renamed their method.
+
+Concretely, we would do the following:
+
+- `narwhals.stable.v1`: you can keep using `Expr.cum_sum`
+- `narwhals.stable.v2`: you can only use `Expr.cumulative_sum`, `Expr.cum_sum` will have been removed
+- `narwhals`:  you can only use `Expr.cumulative_sum`, `Expr.cum_sum` will have been removed
+
+So, although Narwhals' main API (and `narwhals.stable.v2`) will have introduced a breaking change,
+users of `narwhals.stable.v1` will have their code unaffected.
+
+## `import narwhals as nw` or `import narwhals.stable.v1 as nw`?
+
+Which should you use? In general we recommend:
+
+- When prototyping, use `import narwhals as nw`, so you can iterate quickly.
+- Once you're happy with what you've got and what to release something production-ready and stable,
+  when switch out your `import narwhals as nw` usage for `import narwhals.stable.v1 as nw`.
+
+## Exceptions
+
+Are we really promising perfect backwards compatibility in all cases, without exceptions? Not quite.
+There are some exceptions, which we'll now list. But we'll never intentionally break your code.
+Anything currently in `narwhals.stable.v1` will not be changed or removed in future Narwhals versions.
+
+Here are exceptions to our backwards compatibility policy:
+
+- unambiguous bugs. If a function contains what is unambiguously a bug, then we'll fix it, without
+  considering that to be a breaking change.
+- radical changes in backends. Suppose that Polars was to remove
+  expressions, or pandas were to remove support for categorical data. At that point, we might
+  need to rethink Narwhals. However, we expect such radical changes to be exceedingly unlikely.
+- we may consider making some type hints more precise.
diff --git a/docs/index.md b/docs/index.md
index feecd07d..520c1a9e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -8,13 +8,15 @@ Seamlessly support both, without depending on either!
 
 - ✅ **Just use** a subset of **the Polars API**, no need to learn anything new
 - ✅ **Zero dependencies**, Narwhals only uses what
-  the user passes in, so you can keep your library lightweight
+  the user passes in so your library can stay lightweight
 - ✅ Separate **lazy** and eager APIs, use **expressions**
 - ✅ Support pandas' complicated type system and index, without
   either getting in the way
 - ✅ **100% branch coverage**, tested against pandas and Polars nightly builds
 - ✅ **Negligible overhead**, see [overhead](https://narwhals-dev.github.io/narwhals/overhead/)
 - ✅ Let your IDE help you thanks to **full static typing**, see [typing](https://narwhals-dev.github.io/narwhals/typing/)
+- ✅ **Perfect backwards compatibility policy**,
+  see [stable api](https://narwhals-dev.github.io/narwhals/backcompat/) for how to opt-in
 
 ## Who's this for?
 
diff --git a/mkdocs.yml b/mkdocs.yml
index 1c9c76e8..88b43a43 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -14,6 +14,7 @@ nav:
   - Other concepts:
     - other/pandas_index.md
   - overhead.md
+  - backcompat.md
   - extending.md
   - how_it_works.md
   - Roadmap: roadmap.md
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index be0beaac..223a87f9 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -1,4 +1,5 @@
 from narwhals import selectors
+from narwhals import stable
 from narwhals.dataframe import DataFrame
 from narwhals.dataframe import LazyFrame
 from narwhals.dtypes import Boolean
@@ -88,4 +89,5 @@ __all__ = [
     "Date",
     "narwhalify",
     "show_versions",
+    "stable",
 ]
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 10ef379f..0c923f27 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -34,6 +34,11 @@ class Expr:
         # callable from namespace to expr
         self._call = call
 
+    def _taxicab_norm(self) -> Self:
+        # This is just used to test out the stable api feature in a realistic-ish way.
+        # It's not intended to be used.
+        return self.__class__(lambda plx: self._call(plx).abs().sum())
+
     # --- convert ---
     def alias(self, name: str) -> Self:
         """
diff --git a/narwhals/stable/__init__.py b/narwhals/stable/__init__.py
new file mode 100644
index 00000000..572034fe
--- /dev/null
+++ b/narwhals/stable/__init__.py
@@ -0,0 +1,3 @@
+from narwhals.stable import v1
+
+__all__ = ["v1"]
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
new file mode 100644
index 00000000..838eb182
--- /dev/null
+++ b/narwhals/stable/v1.py
@@ -0,0 +1,1279 @@
+from __future__ import annotations
+
+from functools import wraps
+from typing import TYPE_CHECKING
+from typing import Any
+from typing import Callable
+from typing import Iterable
+from typing import Literal
+from typing import Sequence
+from typing import TypeVar
+from typing import overload
+
+import narwhals as nw
+from narwhals import selectors
+from narwhals.dataframe import DataFrame as NwDataFrame
+from narwhals.dataframe import LazyFrame as NwLazyFrame
+from narwhals.dtypes import Boolean
+from narwhals.dtypes import Categorical
+from narwhals.dtypes import Date
+from narwhals.dtypes import Datetime
+from narwhals.dtypes import Duration
+from narwhals.dtypes import Enum
+from narwhals.dtypes import Float32
+from narwhals.dtypes import Float64
+from narwhals.dtypes import Int8
+from narwhals.dtypes import Int16
+from narwhals.dtypes import Int32
+from narwhals.dtypes import Int64
+from narwhals.dtypes import Object
+from narwhals.dtypes import String
+from narwhals.dtypes import UInt8
+from narwhals.dtypes import UInt16
+from narwhals.dtypes import UInt32
+from narwhals.dtypes import UInt64
+from narwhals.dtypes import Unknown
+from narwhals.expression import Expr as NwExpr
+from narwhals.functions import concat
+from narwhals.functions import show_versions
+from narwhals.series import Series as NwSeries
+from narwhals.translate import get_native_namespace as nw_get_native_namespace
+from narwhals.translate import to_native
+from narwhals.typing import IntoDataFrameT
+from narwhals.typing import IntoFrameT
+from narwhals.utils import is_ordered_categorical as nw_is_ordered_categorical
+from narwhals.utils import maybe_align_index as nw_maybe_align_index
+from narwhals.utils import maybe_convert_dtypes as nw_maybe_convert_dtypes
+from narwhals.utils import maybe_set_index as nw_maybe_set_index
+
+if TYPE_CHECKING:
+    from typing_extensions import Self
+
+    from narwhals.dtypes import DType
+    from narwhals.typing import IntoDataFrame
+    from narwhals.typing import IntoExpr
+
+T = TypeVar("T")
+
+
+class DataFrame(NwDataFrame[IntoDataFrameT]):
+    """
+    Narwhals DataFrame, backed by a native dataframe.
+
+    The native dataframe might be pandas.DataFrame, polars.DataFrame, ...
+
+    This class is not meant to be instantiated directly - instead, use
+    `narwhals.from_native`.
+    """
+
+    @overload
+    def __getitem__(self, item: Sequence[int]) -> Series: ...
+
+    @overload
+    def __getitem__(self, item: str) -> Series: ...
+
+    @overload
+    def __getitem__(self, item: slice) -> Self: ...
+
+    def __getitem__(self, item: Any) -> Any:
+        return _stableify(super().__getitem__(item))
+
+    def lazy(self) -> LazyFrame[Any]:
+        """
+        Lazify the DataFrame (if possible).
+
+        If a library does not support lazy execution, then this is a no-op.
+
+        Examples:
+            Construct pandas and Polars DataFrames:
+
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals.stable.v1 as nw
+            >>> df = {"foo": [1, 2, 3], "bar": [6.0, 7.0, 8.0], "ham": ["a", "b", "c"]}
+            >>> df_pd = pd.DataFrame(df)
+            >>> df_pl = pl.DataFrame(df)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(df_any):
+            ...     return df_any.lazy()
+
+            Note that then, pandas dataframe stay eager, but Polars DataFrame becomes a Polars LazyFrame:
+
+            >>> func(df_pd)
+               foo  bar ham
+            0    1  6.0   a
+            1    2  7.0   b
+            2    3  8.0   c
+            >>> func(df_pl)
+            <LazyFrame ...>
+        """
+        return _stableify(super().lazy())  # type: ignore[no-any-return]
+
+    # Not sure what mypy is complaining about, probably some fancy
+    # thing that I need to understand category theory for
+    @overload  # type: ignore[override]
+    def to_dict(self, *, as_series: Literal[True] = ...) -> dict[str, Series]: ...
+    @overload
+    def to_dict(self, *, as_series: Literal[False]) -> dict[str, list[Any]]: ...
+    @overload
+    def to_dict(self, *, as_series: bool) -> dict[str, Series] | dict[str, list[Any]]: ...
+    def to_dict(
+        self, *, as_series: bool = True
+    ) -> dict[str, Series] | dict[str, list[Any]]:
+        """
+        Convert DataFrame to a dictionary mapping column name to values.
+
+        Arguments:
+            as_series: If set to true ``True``, then the values are Narwhals Series,
+                        otherwise the values are Any.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals.stable.v1 as nw
+            >>> df = {
+            ...     "A": [1, 2, 3, 4, 5],
+            ...     "fruits": ["banana", "banana", "apple", "apple", "banana"],
+            ...     "B": [5, 4, 3, 2, 1],
+            ...     "cars": ["beetle", "audi", "beetle", "beetle", "beetle"],
+            ...     "optional": [28, 300, None, 2, -30],
+            ... }
+            >>> df_pd = pd.DataFrame(df)
+            >>> df_pl = pl.DataFrame(df)
+
+            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
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+            {'A': [1, 2, 3, 4, 5], 'fruits': ['banana', 'banana', 'apple', 'apple', 'banana'], 'B': [5, 4, 3, 2, 1], 'cars': ['beetle', 'audi', 'beetle', 'beetle', 'beetle'], 'optional': [28.0, 300.0, nan, 2.0, -30.0]}
+            >>> func(df_pl)
+            {'A': [1, 2, 3, 4, 5], 'fruits': ['banana', 'banana', 'apple', 'apple', 'banana'], 'B': [5, 4, 3, 2, 1], 'cars': ['beetle', 'audi', 'beetle', 'beetle', 'beetle'], 'optional': [28, 300, None, 2, -30]}
+        """
+        if as_series:
+            return {key: _stableify(value) for key, value in super().to_dict().items()}
+        return super().to_dict(as_series=False)
+
+    def is_duplicated(self: Self) -> Series:
+        r"""
+        Get a mask of all duplicated rows in this DataFrame.
+
+        Examples:
+            >>> import narwhals.stable.v1 as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> df_pd = pd.DataFrame(
+            ...     {
+            ...         "a": [1, 2, 3, 1],
+            ...         "b": ["x", "y", "z", "x"],
+            ...     }
+            ... )
+            >>> df_pl = pl.DataFrame(
+            ...     {
+            ...         "a": [1, 2, 3, 1],
+            ...         "b": ["x", "y", "z", "x"],
+            ...     }
+            ... )
+
+            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)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            0     True
+            1    False
+            2    False
+            3     True
+            dtype: bool
+
+            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [bool]
+            [
+                true
+                false
+                false
+                true
+            ]
+        """
+        return _stableify(super().is_duplicated())
+
+    def is_unique(self: Self) -> Series:
+        r"""
+        Get a mask of all unique rows in this DataFrame.
+
+        Examples:
+            >>> import narwhals.stable.v1 as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> df_pd = pd.DataFrame(
+            ...     {
+            ...         "a": [1, 2, 3, 1],
+            ...         "b": ["x", "y", "z", "x"],
+            ...     }
+            ... )
+            >>> df_pl = pl.DataFrame(
+            ...     {
+            ...         "a": [1, 2, 3, 1],
+            ...         "b": ["x", "y", "z", "x"],
+            ...     }
+            ... )
+
+            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)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+            0    False
+            1     True
+            2     True
+            3    False
+            dtype: bool
+
+            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (4,)
+            Series: '' [bool]
+            [
+                false
+                 true
+                 true
+                false
+            ]
+        """
+        return _stableify(super().is_unique())
+
+
+class LazyFrame(NwLazyFrame[IntoFrameT]):
+    """
+    Narwhals DataFrame, backed by a native dataframe.
+
+    The native dataframe might be pandas.DataFrame, polars.LazyFrame, ...
+
+    This class is not meant to be instantiated directly - instead, use
+    `narwhals.from_native`.
+    """
+
+    def collect(self) -> DataFrame[Any]:
+        r"""
+        Materialize this LazyFrame into a DataFrame.
+
+        Returns:
+            DataFrame
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import polars as pl
+            >>> lf_pl = pl.LazyFrame(
+            ...     {
+            ...         "a": ["a", "b", "a", "b", "b", "c"],
+            ...         "b": [1, 2, 3, 4, 5, 6],
+            ...         "c": [6, 5, 4, 3, 2, 1],
+            ...     }
+            ... )
+            >>> lf = nw.from_native(lf_pl)
+            >>> lf
+            ┌───────────────────────────────────────────────┐
+            | Narwhals LazyFrame                            |
+            | Use `narwhals.to_native` to see native output |
+            └───────────────────────────────────────────────┘
+            >>> df = lf.group_by("a").agg(nw.all().sum()).collect()
+            >>> nw.to_native(df).sort("a")
+            shape: (3, 3)
+            ┌─────┬─────┬─────┐
+            │ a   ┆ b   ┆ c   │
+            │ --- ┆ --- ┆ --- │
+            │ str ┆ i64 ┆ i64 │
+            ╞═════╪═════╪═════╡
+            │ a   ┆ 4   ┆ 10  │
+            │ b   ┆ 11  ┆ 10  │
+            │ c   ┆ 6   ┆ 1   │
+            └─────┴─────┴─────┘
+        """
+        return _stableify(super().collect())  # type: ignore[no-any-return]
+
+
+class Series(NwSeries):
+    """
+    Narwhals Series, backed by a native series.
+
+    The native dataframe might be pandas.Series, polars.Series, ...
+
+    This class is not meant to be instantiated directly - instead, use
+    `narwhals.from_native`, making sure to pass `allow_series=True` or
+    `series_only=True`.
+    """
+
+    def to_frame(self) -> DataFrame[Any]:
+        """
+        Convert to dataframe.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals.stable.v1 as nw
+            >>> s = [1, 2, 3]
+            >>> s_pd = pd.Series(s, name="a")
+            >>> s_pl = pl.Series("a", s)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s_any):
+            ...     return s_any.to_frame()
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)
+               a
+            0  1
+            1  2
+            2  3
+            >>> func(s_pl)
+            shape: (3, 1)
+            ┌─────┐
+            │ a   │
+            │ --- │
+            │ i64 │
+            ╞═════╡
+            │ 1   │
+            │ 2   │
+            │ 3   │
+            └─────┘
+        """
+        return _stableify(super().to_frame())  # type: ignore[no-any-return]
+
+    def value_counts(
+        self: Self, *, sort: bool = False, parallel: bool = False
+    ) -> DataFrame[Any]:
+        r"""
+        Count the occurrences of unique values.
+
+        Arguments:
+            sort: Sort the output by count in descending order. If set to False (default),
+                the order of the output is random.
+            parallel: Execute the computation in parallel. Unused for pandas-like APIs.
+
+        Examples:
+            >>> import narwhals.stable.v1 as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> s_pd = pd.Series([1, 1, 2, 3, 2], name="s")
+            >>> s_pl = pl.Series(values=[1, 1, 2, 3, 2], name="s")
+
+            Let's define a dataframe-agnostic function:
+
+            >>> @nw.narwhalify
+            ... def func(s_any):
+            ...     return s_any.value_counts(sort=True)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)  # doctest: +NORMALIZE_WHITESPACE
+               s  count
+            0  1      2
+            1  2      2
+            2  3      1
+
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (3, 2)
+            ┌─────┬───────┐
+            │ s   ┆ count │
+            │ --- ┆ ---   │
+            │ i64 ┆ u32   │
+            ╞═════╪═══════╡
+            │ 1   ┆ 2     │
+            │ 2   ┆ 2     │
+            │ 3   ┆ 1     │
+            └─────┴───────┘
+        """
+        return _stableify(super().value_counts(sort=sort, parallel=parallel))  # type: ignore[no-any-return]
+
+
+class Expr(NwExpr):
+    def _l1_norm(self) -> Self:
+        return super()._taxicab_norm()
+
+
+@overload
+def _stableify(obj: NwDataFrame[IntoFrameT]) -> DataFrame[IntoFrameT]: ...
+@overload
+def _stableify(obj: NwLazyFrame[IntoFrameT]) -> LazyFrame[IntoFrameT]: ...
+@overload
+def _stableify(obj: NwSeries) -> Series: ...
+@overload
+def _stableify(obj: NwExpr) -> Expr: ...
+@overload
+def _stableify(obj: Any) -> Any: ...
+
+
+def _stableify(
+    obj: NwDataFrame[IntoFrameT] | NwLazyFrame[IntoFrameT] | NwSeries | NwExpr | Any,
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | Series | Expr | Any:
+    if isinstance(obj, NwDataFrame):
+        return DataFrame(
+            obj._dataframe, is_polars=obj._is_polars, backend_version=obj._backend_version
+        )
+    if isinstance(obj, NwLazyFrame):
+        return LazyFrame(
+            obj._dataframe, is_polars=obj._is_polars, backend_version=obj._backend_version
+        )
+    if isinstance(obj, NwSeries):
+        return Series(
+            obj._series, is_polars=obj._is_polars, backend_version=obj._backend_version
+        )
+    if isinstance(obj, NwExpr):
+        return Expr(obj._call)
+    return obj
+
+
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[False],
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: Literal[True],
+) -> Any: ...
+
+
+@overload
+def from_native(
+    native_dataframe: IntoDataFrame | T,
+    *,
+    strict: Literal[False],
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: None = ...,
+) -> DataFrame[IntoDataFrame] | T: ...
+
+
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[False],
+    eager_only: None = ...,
+    series_only: None = ...,
+    allow_series: Literal[True],
+) -> Any: ...
+
+
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[False],
+    eager_only: None = ...,
+    series_only: Literal[True],
+    allow_series: None = ...,
+) -> Any: ...
+
+
+# from_native(df, strict=False)
+@overload
+def from_native(
+    native_dataframe: IntoFrameT | T,
+    *,
+    strict: Literal[False],
+    eager_only: bool | None = ...,
+    series_only: bool | None = ...,
+    allow_series: bool | None = ...,
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | T: ...
+
+
+# from_native(df, strict=True, eager_only=True, allow_series=True)
+# from_native(df, eager_only=True, allow_series=True)
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: Literal[True],
+) -> DataFrame[Any] | Series: ...
+
+
+# from_native(df, strict=True, eager_only=True)
+# from_native(df, eager_only=True)
+@overload
+def from_native(
+    native_dataframe: IntoDataFrameT,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: Literal[True],
+    series_only: None = ...,
+    allow_series: None = ...,
+) -> DataFrame[IntoDataFrameT]: ...
+
+
+# from_native(df, strict=True, allow_series=True)
+# from_native(df, allow_series=True)
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: None = ...,
+    series_only: None = ...,
+    allow_series: Literal[True],
+) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
+
+
+# from_native(df, strict=True, series_only=True)
+# from_native(df, series_only=True)
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: None = ...,
+    series_only: Literal[True],
+    allow_series: None = ...,
+) -> Series: ...
+
+
+# from_native(df, strict=True)
+# from_native(df)
+@overload
+def from_native(
+    native_dataframe: IntoFrameT,
+    *,
+    strict: Literal[True] = ...,
+    eager_only: None = ...,
+    series_only: None = ...,
+    allow_series: None = ...,
+) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ...
+
+
+# All params passed in as variables
+@overload
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: bool,
+    eager_only: bool | None,
+    series_only: bool | None,
+    allow_series: bool | None,
+) -> Any: ...
+
+
+def from_native(
+    native_dataframe: Any,
+    *,
+    strict: bool = True,
+    eager_only: bool | None = None,
+    series_only: bool | None = None,
+    allow_series: bool | None = None,
+) -> Any:
+    """
+    Convert dataframe to Narwhals DataFrame, LazyFrame, or Series.
+
+    Arguments:
+        native_dataframe: Raw dataframe from user.
+            Depending on the other arguments, input object can be:
+
+            - pandas.DataFrame
+            - polars.DataFrame
+            - polars.LazyFrame
+            - anything with a `__narwhals_dataframe__` or `__narwhals_lazyframe__` method
+            - pandas.Series
+            - polars.Series
+            - anything with a `__narwhals_series__` method
+        strict: Whether to raise if object can't be converted (default) or
+            to just leave it as-is.
+        eager_only: Whether to only allow eager objects.
+        series_only: Whether to only allow series.
+        allow_series: Whether to allow series (default is only dataframe / lazyframe).
+
+    Returns:
+        narwhals.DataFrame or narwhals.LazyFrame or narwhals.Series
+    """
+    result = nw.from_native(
+        native_dataframe,
+        strict=strict,
+        eager_only=eager_only,
+        series_only=series_only,
+        allow_series=allow_series,
+    )
+    return _stableify(result)
+
+
+def narwhalify(
+    func: Callable[..., Any] | None = None,
+    *,
+    strict: bool = False,
+    eager_only: bool | None = False,
+    series_only: bool | None = False,
+    allow_series: bool | None = True,
+) -> Callable[..., Any]:
+    """
+    Decorate function so it becomes dataframe-agnostic.
+
+    `narwhalify` will try to convert any dataframe/series-like object into the narwhal
+    respective DataFrame/Series, while leaving the other parameters as they are.
+
+    Similarly, if the output of the function is a narwhals DataFrame or Series, it will be
+    converted back to the original dataframe/series type, while if the output is another
+    type it will be left as is.
+
+    By setting `strict=True`, then every input and every output will be required to be a
+    dataframe/series-like object.
+
+    Instead of writing
+
+    ```python
+    import narwhals.stable.v1 as nw
+
+
+    def func(df_any):
+        df = nw.from_native(df_any, strict=False)
+        df = df.group_by("a").agg(nw.col("b").sum())
+        return nw.to_native(df)
+    ```
+
+    you can just write
+
+    ```python
+    import narwhals.stable.v1 as nw
+
+
+    @nw.narwhalify
+    def func(df):
+        return df.group_by("a").agg(nw.col("b").sum())
+    ```
+
+    You can also pass in extra arguments, e.g.
+
+    ```python
+    @nw.narhwalify(eager_only=True)
+    ```
+
+    that will get passed down to `nw.from_native`.
+
+    Arguments:
+        func: Function to wrap in a `from_native`-`to_native` block.
+        strict: Whether to raise if object can't be converted or to just leave it as-is
+            (default).
+        eager_only: Whether to only allow eager objects.
+        series_only: Whether to only allow series.
+        allow_series: Whether to allow series (default is only dataframe / lazyframe).
+    """
+
+    # TODO: do we have a way to de-dupe this a bit?
+    def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
+        @wraps(func)
+        def wrapper(*args: Any, **kwargs: Any) -> Any:
+            args = [
+                from_native(
+                    arg,
+                    strict=strict,
+                    eager_only=eager_only,
+                    series_only=series_only,
+                    allow_series=allow_series,
+                )
+                for arg in args
+            ]  # type: ignore[assignment]
+
+            kwargs = {
+                name: from_native(
+                    value,
+                    strict=strict,
+                    eager_only=eager_only,
+                    series_only=series_only,
+                    allow_series=allow_series,
+                )
+                for name, value in kwargs.items()
+            }
+
+            backends = {
+                b()
+                for v in [*args, *kwargs.values()]
+                if (b := getattr(v, "__native_namespace__", None))
+            }
+
+            if backends.__len__() > 1:
+                msg = "Found multiple backends. Make sure that all dataframe/series inputs come from the same backend."
+                raise ValueError(msg)
+
+            result = func(*args, **kwargs)
+
+            return to_native(result, strict=strict)
+
+        return wrapper
+
+    if func is None:
+        return decorator
+    else:
+        # If func is not None, it means the decorator is used without arguments
+        return decorator(func)
+
+
+def all() -> Expr:
+    """
+    Instantiate an expression representing all columns.
+
+    Examples:
+        >>> import polars as pl
+        >>> import pandas as pd
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
+
+        Let's define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.all() * 2)
+
+        We can then pass either pandas or Polars to `func`:
+
+        >>> func(df_pd)
+           a   b
+        0  2   8
+        1  4  10
+        2  6  12
+        >>> func(df_pl)
+        shape: (3, 2)
+        ┌─────┬─────┐
+        │ a   ┆ b   │
+        │ --- ┆ --- │
+        │ i64 ┆ i64 │
+        ╞═════╪═════╡
+        │ 2   ┆ 8   │
+        │ 4   ┆ 10  │
+        │ 6   ┆ 12  │
+        └─────┴─────┘
+    """
+    return _stableify(nw.all())
+
+
+def col(*names: str | Iterable[str]) -> Expr:
+    """
+    Creates an expression that references one or more columns by their name(s).
+
+    Arguments:
+        names: Name(s) of the columns to use in the aggregation function.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [3, 4]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [3, 4]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.col("a") * nw.col("b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a
+        0  3
+        1  8
+        >>> func(df_pl)
+        shape: (2, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ i64 │
+        ╞═════╡
+        │ 3   │
+        │ 8   │
+        └─────┘
+    """
+    return _stableify(nw.col(*names))
+
+
+def len() -> Expr:
+    """
+    Return the number of rows.
+
+    Examples:
+        >>> import polars as pl
+        >>> import pandas as pd
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [5, 10]})
+        >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [5, 10]})
+
+        Let's define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.len())
+
+        We can then pass either pandas or Polars to `func`:
+
+        >>> func(df_pd)
+           len
+        0    2
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ len │
+        │ --- │
+        │ u32 │
+        ╞═════╡
+        │ 2   │
+        └─────┘
+    """
+    return _stableify(nw.len())
+
+
+def lit(value: Any, dtype: DType | None = None) -> Expr:
+    """
+    Return an expression representing a literal value.
+
+    Arguments:
+        value: The value to use as literal.
+        dtype: The data type of the literal value. If not provided, the data type will be inferred.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.with_columns(nw.lit(3).alias("b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a  b
+        0  1  3
+        1  2  3
+        >>> func(df_pl)
+        shape: (2, 2)
+        ┌─────┬─────┐
+        │ a   ┆ b   │
+        │ --- ┆ --- │
+        │ i64 ┆ i32 │
+        ╞═════╪═════╡
+        │ 1   ┆ 3   │
+        │ 2   ┆ 3   │
+        └─────┴─────┘
+
+    """
+    return _stableify(nw.lit(value, dtype))
+
+
+def min(*columns: str) -> Expr:
+    """
+    Return the minimum value.
+
+    Note:
+       Syntactic sugar for ``nw.col(columns).min()``.
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function.
+
+    Examples:
+        >>> import polars as pl
+        >>> import pandas as pd
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [5, 10]})
+        >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [5, 10]})
+
+        Let's define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.min("b"))
+
+        We can then pass either pandas or Polars to `func`:
+
+        >>> func(df_pd)
+           b
+        0  5
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ b   │
+        │ --- │
+        │ i64 │
+        ╞═════╡
+        │ 5   │
+        └─────┘
+    """
+    return _stableify(nw.min(*columns))
+
+
+def max(*columns: str) -> Expr:
+    """
+    Return the maximum value.
+
+    Note:
+       Syntactic sugar for ``nw.col(columns).max()``.
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function.
+
+    Examples:
+        >>> import polars as pl
+        >>> import pandas as pd
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [5, 10]})
+        >>> df_pl = pl.DataFrame({"a": [1, 2], "b": [5, 10]})
+
+        Let's define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.max("a"))
+
+        We can then pass either pandas or Polars to `func`:
+
+        >>> func(df_pd)
+           a
+        0  2
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ i64 │
+        ╞═════╡
+        │ 2   │
+        └─────┘
+    """
+    return _stableify(nw.max(*columns))
+
+
+def mean(*columns: str) -> Expr:
+    """
+    Get the mean value.
+
+    Note:
+        Syntactic sugar for ``nw.col(columns).mean()``
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 8, 3]})
+        >>> df_pd = pd.DataFrame({"a": [1, 8, 3]})
+
+        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)
+
+        We can then pass either pandas or Polars to `func`:
+
+        >>> func(df_pd)
+             a
+        0  4.0
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ f64 │
+        ╞═════╡
+        │ 4.0 │
+        └─────┘
+    """
+    return _stableify(nw.mean(*columns))
+
+
+def sum(*columns: str) -> Expr:
+    """
+    Sum all values.
+
+    Note:
+        Syntactic sugar for ``nw.col(columns).sum()``
+
+    Arguments:
+        columns: Name(s) of the columns to use in the aggregation function
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.sum("a"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a
+        0  3
+        >>> func(df_pl)
+        shape: (1, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ i64 │
+        ╞═════╡
+        │ 3   │
+        └─────┘
+    """
+    return _stableify(nw.sum(*columns))
+
+
+def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
+    """
+    Sum all values horizontally across columns
+
+    Arguments:
+        exprs: Name(s) of the columns to use in the aggregation function. Accepts expression input.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2, 3], "b": [5, 10, 15]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.select(nw.sum_horizontal("a", "b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+            a
+        0   6
+        1  12
+        2  18
+        >>> func(df_pl)
+        shape: (3, 1)
+        ┌─────┐
+        │ a   │
+        │ --- │
+        │ i64 │
+        ╞═════╡
+        │ 6   │
+        │ 12  │
+        │ 18  │
+        └─────┘
+
+    """
+    return _stableify(nw.sum_horizontal(*exprs))
+
+
+def is_ordered_categorical(series: Series) -> bool:
+    """
+    Return whether indices of categories are semantically meaningful.
+
+    This is a convenience function to accessing what would otherwise be
+    the `is_ordered` property from the DataFrame Interchange Protocol,
+    see https://data-apis.org/dataframe-protocol/latest/API.html.
+
+    - For Polars:
+      - Enums are always ordered.
+      - Categoricals are ordered if `dtype.ordering == "physical"`.
+    - For pandas-like APIs:
+      - Categoricals are ordered if `dtype.cat.ordered == True`.
+    - For PyArrow table:
+      - Categoricals are ordered if `dtype.type.ordered == True`.
+
+    Examples:
+        >>> import narwhals.stable.v1 as nw
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> data = ["x", "y"]
+        >>> s_pd = pd.Series(data, dtype=pd.CategoricalDtype(ordered=True))
+        >>> s_pl = pl.Series(data, dtype=pl.Categorical(ordering="physical"))
+
+        Let's define a library-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(s):
+        ...     return nw.is_ordered_categorical(s)
+
+        Then, we can pass any supported library to `func`:
+
+        >>> func(s_pd)
+        True
+        >>> func(s_pl)
+        True
+    """
+    return nw_is_ordered_categorical(series)
+
+
+def maybe_align_index(lhs: T, rhs: Series | DataFrame[Any] | LazyFrame[Any]) -> T:
+    """
+    Align `lhs` to the Index of `rhs, if they're both pandas-like.
+
+    Notes:
+        This is only really intended for backwards-compatibility purposes,
+        for example if your library already aligns indices for users.
+        If you're designing a new library, we highly encourage you to not
+        rely on the Index.
+        For non-pandas-like inputs, this only checks that `lhs` and `rhs`
+        are the same length.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2]}, index=[3, 4])
+        >>> s_pd = pd.Series([6, 7], index=[4, 3])
+        >>> df = nw.from_native(df_pd)
+        >>> s = nw.from_native(s_pd, series_only=True)
+        >>> nw.to_native(nw.maybe_align_index(df, s))
+           a
+        4  2
+        3  1
+    """
+    return nw_maybe_align_index(lhs, rhs)
+
+
+def maybe_convert_dtypes(df: T, *args: bool, **kwargs: bool | str) -> T:
+    """
+    Convert columns to the best possible dtypes using dtypes supporting ``pd.NA``, if df is pandas-like.
+
+    Notes:
+        For non-pandas-like inputs, this is a no-op.
+        Also, `args` and `kwargs` just get passed down to the underlying library as-is.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> import numpy as np
+        >>> df_pd = pd.DataFrame(
+        ...     {
+        ...         "a": pd.Series([1, 2, 3], dtype=np.dtype("int32")),
+        ...         "b": pd.Series([True, False, np.nan], dtype=np.dtype("O")),
+        ...     }
+        ... )
+        >>> df = nw.from_native(df_pd)
+        >>> nw.to_native(nw.maybe_convert_dtypes(df)).dtypes  # doctest: +NORMALIZE_WHITESPACE
+        a             Int32
+        b           boolean
+        dtype: object
+    """
+    return nw_maybe_convert_dtypes(df, *args, **kwargs)
+
+
+def maybe_set_index(df: T, column_names: str | list[str]) -> T:
+    """
+    Set columns `columns` to be the index of `df`, if `df` is pandas-like.
+
+    Notes:
+        This is only really intended for backwards-compatibility purposes,
+        for example if your library already aligns indices for users.
+        If you're designing a new library, we highly encourage you to not
+        rely on the Index.
+        For non-pandas-like inputs, this is a no-op.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals.stable.v1 as nw
+        >>> df_pd = pd.DataFrame({"a": [1, 2], "b": [4, 5]})
+        >>> df = nw.from_native(df_pd)
+        >>> nw.to_native(nw.maybe_set_index(df, "b"))  # doctest: +NORMALIZE_WHITESPACE
+           a
+        b
+        4  1
+        5  2
+    """
+    return nw_maybe_set_index(df, column_names)
+
+
+def get_native_namespace(obj: Any) -> Any:
+    """
+    Get native namespace from object.
+
+    Examples:
+        >>> import polars as pl
+        >>> import pandas as pd
+        >>> import narwhals.stable.v1 as nw
+        >>> df = nw.from_native(pd.DataFrame({"a": [1, 2, 3]}))
+        >>> nw.get_native_namespace(df)
+        <module 'pandas'...>
+        >>> df = nw.from_native(pl.DataFrame({"a": [1, 2, 3]}))
+        >>> nw.get_native_namespace(df)
+        <module 'polars'...>
+    """
+    return nw_get_native_namespace(obj)
+
+
+__all__ = [
+    "selectors",
+    "concat",
+    "to_native",
+    "from_native",
+    "is_ordered_categorical",
+    "maybe_align_index",
+    "maybe_convert_dtypes",
+    "maybe_set_index",
+    "get_native_namespace",
+    "all",
+    "col",
+    "len",
+    "lit",
+    "min",
+    "max",
+    "mean",
+    "sum",
+    "sum_horizontal",
+    "DataFrame",
+    "LazyFrame",
+    "Series",
+    "Expr",
+    "Int64",
+    "Int32",
+    "Int16",
+    "Int8",
+    "UInt64",
+    "UInt32",
+    "UInt16",
+    "UInt8",
+    "Float64",
+    "Float32",
+    "Boolean",
+    "Object",
+    "Unknown",
+    "Categorical",
+    "Enum",
+    "String",
+    "Datetime",
+    "Duration",
+    "Date",
+    "narwhalify",
+    "show_versions",
+]
diff --git a/narwhals/translate.py b/narwhals/translate.py
index 9914bab6..0683668b 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -132,6 +132,7 @@ def from_native(
 ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT] | T: ...
 
 
+# from_native(df, strict=False)
 @overload
 def from_native(
     native_dataframe: Any,
@@ -143,6 +144,8 @@ def from_native(
 ) -> DataFrame[Any] | Series: ...
 
 
+# from_native(df, strict=True, eager_only=True, allow_series=True)
+# from_native(df, eager_only=True, allow_series=True)
 @overload
 def from_native(
     native_dataframe: IntoDataFrameT,
@@ -154,6 +157,8 @@ def from_native(
 ) -> DataFrame[IntoDataFrameT]: ...
 
 
+# from_native(df, strict=True, eager_only=True)
+# from_native(df, eager_only=True)
 @overload
 def from_native(
     native_dataframe: Any,
@@ -165,6 +170,8 @@ def from_native(
 ) -> DataFrame[Any] | LazyFrame[Any] | Series: ...
 
 
+# from_native(df, strict=True, series_only=True)
+# from_native(df, series_only=True)
 @overload
 def from_native(
     native_dataframe: Any,
@@ -176,6 +183,8 @@ def from_native(
 ) -> Series: ...
 
 
+# from_native(df, strict=True)
+# from_native(df)
 @overload
 def from_native(
     native_dataframe: IntoFrameT,
@@ -187,7 +196,7 @@ def from_native(
 ) -> DataFrame[IntoFrameT] | LazyFrame[IntoFrameT]: ...
 
 
-# Nothing was specified
+# All params passed in as variables
 @overload
 def from_native(
     native_dataframe: Any,


Test patch:
diff --git a/tests/expr/abs_test.py b/tests/expr/abs_test.py
index eb558d13..060409e4 100644
--- a/tests/expr/abs_test.py
+++ b/tests/expr/abs_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/any_all_test.py b/tests/expr/any_all_test.py
index c5bb88cb..d71b8277 100644
--- a/tests/expr/any_all_test.py
+++ b/tests/expr/any_all_test.py
@@ -3,7 +3,7 @@ from typing import Any
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
diff --git a/tests/expr/cast_test.py b/tests/expr/cast_test.py
index 7e647296..852dc031 100644
--- a/tests/expr/cast_test.py
+++ b/tests/expr/cast_test.py
@@ -3,7 +3,7 @@ from typing import Any
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 
diff --git a/tests/expr/cat/get_categories_test.py b/tests/expr/cat/get_categories_test.py
index 66088173..a94942b0 100644
--- a/tests/expr/cat/get_categories_test.py
+++ b/tests/expr/cat/get_categories_test.py
@@ -4,7 +4,7 @@ from typing import Any
 
 import pyarrow as pa
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": ["one", "two", "two"]}
diff --git a/tests/expr/cum_sum_test.py b/tests/expr/cum_sum_test.py
index 25e3597e..25f8b192 100644
--- a/tests/expr/cum_sum_test.py
+++ b/tests/expr/cum_sum_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/diff_test.py b/tests/expr/diff_test.py
index f6865cb2..234d3a73 100644
--- a/tests/expr/diff_test.py
+++ b/tests/expr/diff_test.py
@@ -3,7 +3,7 @@ from typing import Any
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
diff --git a/tests/expr/double_selected_test.py b/tests/expr/double_selected_test.py
index 948e8e5e..64811f65 100644
--- a/tests/expr/double_selected_test.py
+++ b/tests/expr/double_selected_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/double_test.py b/tests/expr/double_test.py
index d83535fd..64476882 100644
--- a/tests/expr/double_test.py
+++ b/tests/expr/double_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/fill_null_test.py b/tests/expr/fill_null_test.py
index 50781a79..909d6e76 100644
--- a/tests/expr/fill_null_test.py
+++ b/tests/expr/fill_null_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/filter_test.py b/tests/expr/filter_test.py
index b7a3eac8..1311ccc2 100644
--- a/tests/expr/filter_test.py
+++ b/tests/expr/filter_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/is_between_test.py b/tests/expr/is_between_test.py
index fec8f239..382bf37a 100644
--- a/tests/expr/is_between_test.py
+++ b/tests/expr/is_between_test.py
@@ -4,7 +4,7 @@ from typing import Any
 
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/is_duplicated_test.py b/tests/expr/is_duplicated_test.py
index 01bbb62e..f174f299 100644
--- a/tests/expr/is_duplicated_test.py
+++ b/tests/expr/is_duplicated_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/is_first_distinct_test.py b/tests/expr/is_first_distinct_test.py
index a62717fe..bebd7fca 100644
--- a/tests/expr/is_first_distinct_test.py
+++ b/tests/expr/is_first_distinct_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/is_last_distinct_test.py b/tests/expr/is_last_distinct_test.py
index ef128d8d..99d8a862 100644
--- a/tests/expr/is_last_distinct_test.py
+++ b/tests/expr/is_last_distinct_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/is_unique_test.py b/tests/expr/is_unique_test.py
index 8271a9bb..cfb97a87 100644
--- a/tests/expr/is_unique_test.py
+++ b/tests/expr/is_unique_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/len_test.py b/tests/expr/len_test.py
index 5f5de569..0b4b71a8 100644
--- a/tests/expr/len_test.py
+++ b/tests/expr/len_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": list("xyz"), "b": [1, 2, 1]}
diff --git a/tests/expr/n_unique_test.py b/tests/expr/n_unique_test.py
index 55c3e4d6..98dd0a85 100644
--- a/tests/expr/n_unique_test.py
+++ b/tests/expr/n_unique_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/null_count_test.py b/tests/expr/null_count_test.py
index 8b7fc0a5..5461db86 100644
--- a/tests/expr/null_count_test.py
+++ b/tests/expr/null_count_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/over_test.py b/tests/expr/over_test.py
index 872ac0a1..e85bd476 100644
--- a/tests/expr/over_test.py
+++ b/tests/expr/over_test.py
@@ -3,7 +3,7 @@ from typing import Any
 import pandas as pd
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/round_test.py b/tests/expr/round_test.py
index c52ea676..add6adca 100644
--- a/tests/expr/round_test.py
+++ b/tests/expr/round_test.py
@@ -4,7 +4,7 @@ from typing import Any
 
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/sample_test.py b/tests/expr/sample_test.py
index cb45555b..c85d0555 100644
--- a/tests/expr/sample_test.py
+++ b/tests/expr/sample_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_expr_sample(constructor: Any) -> None:
diff --git a/tests/expr/shift_test.py b/tests/expr/shift_test.py
index 5564c764..18ad37ae 100644
--- a/tests/expr/shift_test.py
+++ b/tests/expr/shift_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/str/contains_test.py b/tests/expr/str/contains_test.py
index e38c1be0..32e45de0 100644
--- a/tests/expr/str/contains_test.py
+++ b/tests/expr/str/contains_test.py
@@ -4,7 +4,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"pets": ["cat", "dog", "rabbit and parrot", "dove"]}
diff --git a/tests/expr/str/head_test.py b/tests/expr/str/head_test.py
index 31ae9499..547a4cfc 100644
--- a/tests/expr/str/head_test.py
+++ b/tests/expr/str/head_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/str/slice_test.py b/tests/expr/str/slice_test.py
index d2c5861c..e3831952 100644
--- a/tests/expr/str/slice_test.py
+++ b/tests/expr/str/slice_test.py
@@ -4,7 +4,7 @@ from typing import Any
 
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": ["fdas", "edfas"]}
diff --git a/tests/expr/str/starts_with_ends_with_test.py b/tests/expr/str/starts_with_ends_with_test.py
index 13c7ddf6..3eadd501 100644
--- a/tests/expr/str/starts_with_ends_with_test.py
+++ b/tests/expr/str/starts_with_ends_with_test.py
@@ -6,7 +6,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 # Don't move this into typechecking block, for coverage
 # purposes
diff --git a/tests/expr/str/tail_test.py b/tests/expr/str/tail_test.py
index e9e67e5a..adf7365b 100644
--- a/tests/expr/str/tail_test.py
+++ b/tests/expr/str/tail_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/expr/str/to_datetime_test.py b/tests/expr/str/to_datetime_test.py
index 3c19df53..62c438f8 100644
--- a/tests/expr/str/to_datetime_test.py
+++ b/tests/expr/str/to_datetime_test.py
@@ -5,7 +5,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+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"]})
diff --git a/tests/expr/sum_all_test.py b/tests/expr/sum_all_test.py
index 29871002..dda7c7ce 100644
--- a/tests/expr/sum_all_test.py
+++ b/tests/expr/sum_all_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/sum_horizontal_test.py b/tests/expr/sum_horizontal_test.py
index 4c37022a..3c4600bb 100644
--- a/tests/expr/sum_horizontal_test.py
+++ b/tests/expr/sum_horizontal_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/expr/test_dt.py b/tests/expr/test_dt.py
index 4aa189c5..18ab04de 100644
--- a/tests/expr/test_dt.py
+++ b/tests/expr/test_dt.py
@@ -13,7 +13,7 @@ import pyarrow as pa
 import pytest
 from hypothesis import given
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 from tests.utils import is_windows
diff --git a/tests/frame/add_test.py b/tests/frame/add_test.py
index 405dbacb..68d13d07 100644
--- a/tests/frame/add_test.py
+++ b/tests/frame/add_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/clone_test.py b/tests/frame/clone_test.py
index 9d983506..5d157c60 100644
--- a/tests/frame/clone_test.py
+++ b/tests/frame/clone_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/double_test.py b/tests/frame/double_test.py
index 1495195c..ae06f7cf 100644
--- a/tests/frame/double_test.py
+++ b/tests/frame/double_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/drop_nulls_test.py b/tests/frame/drop_nulls_test.py
index 0646c803..8e2a2d33 100644
--- a/tests/frame/drop_nulls_test.py
+++ b/tests/frame/drop_nulls_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/frame/filter_test.py b/tests/frame/filter_test.py
index 5dd9e714..158026a8 100644
--- a/tests/frame/filter_test.py
+++ b/tests/frame/filter_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/len_test.py b/tests/frame/len_test.py
index 65f8d383..f624b17a 100644
--- a/tests/frame/len_test.py
+++ b/tests/frame/len_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 data = {
     "a": [1.0, 2.0, None, 4.0],
diff --git a/tests/frame/pipe_test.py b/tests/frame/pipe_test.py
index 5fc9bd6d..6f49966b 100644
--- a/tests/frame/pipe_test.py
+++ b/tests/frame/pipe_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/frame/rename_test.py b/tests/frame/rename_test.py
index b5739373..ab901803 100644
--- a/tests/frame/rename_test.py
+++ b/tests/frame/rename_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/rows_test.py b/tests/frame/rows_test.py
index ccd3cdfb..165d33bb 100644
--- a/tests/frame/rows_test.py
+++ b/tests/frame/rows_test.py
@@ -7,7 +7,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 df_pandas = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
diff --git a/tests/frame/schema_test.py b/tests/frame/schema_test.py
index 8abbb349..e2b79eac 100644
--- a/tests/frame/schema_test.py
+++ b/tests/frame/schema_test.py
@@ -8,7 +8,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 data = {
diff --git a/tests/frame/select_test.py b/tests/frame/select_test.py
index 856fa2ee..abe936a0 100644
--- a/tests/frame/select_test.py
+++ b/tests/frame/select_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/shape_test.py b/tests/frame/shape_test.py
index 67496719..32647d53 100644
--- a/tests/frame/shape_test.py
+++ b/tests/frame/shape_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_shape(constructor_with_pyarrow: Any) -> None:
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 83ed31fe..45390c56 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -6,7 +6,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/frame/sort_test.py b/tests/frame/sort_test.py
index a5dd8e0b..c9069a51 100644
--- a/tests/frame/sort_test.py
+++ b/tests/frame/sort_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index f6d08393..9de5206d 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -12,7 +12,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.functions import _get_deps_info
 from narwhals.functions import _get_sys_info
 from narwhals.functions import show_versions
diff --git a/tests/frame/test_invalid.py b/tests/frame/test_invalid.py
index 77cafa4f..2537eef5 100644
--- a/tests/frame/test_invalid.py
+++ b/tests/frame/test_invalid.py
@@ -2,7 +2,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 
diff --git a/tests/frame/to_dict_test.py b/tests/frame/to_dict_test.py
index 62031173..b0950c5c 100644
--- a/tests/frame/to_dict_test.py
+++ b/tests/frame/to_dict_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_to_dict(constructor: Any) -> None:
diff --git a/tests/frame/with_columns_sequence_test.py b/tests/frame/with_columns_sequence_test.py
index 335e1512..d44a493d 100644
--- a/tests/frame/with_columns_sequence_test.py
+++ b/tests/frame/with_columns_sequence_test.py
@@ -2,7 +2,7 @@ from typing import Any
 
 import numpy as np
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/frame/with_row_index_test.py b/tests/frame/with_row_index_test.py
index 5d705e4a..1b0ad579 100644
--- a/tests/frame/with_row_index_test.py
+++ b/tests/frame/with_row_index_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {
diff --git a/tests/frame/write_parquet_test.py b/tests/frame/write_parquet_test.py
index db0835be..88e35b3f 100644
--- a/tests/frame/write_parquet_test.py
+++ b/tests/frame/write_parquet_test.py
@@ -6,7 +6,7 @@ from typing import Any
 import pandas as pd
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 data = {"a": [1, 2, 3]}
diff --git a/tests/hypothesis/test_basic_arithmetic.py b/tests/hypothesis/test_basic_arithmetic.py
index 5d7bcf21..2ab7bad7 100644
--- a/tests/hypothesis/test_basic_arithmetic.py
+++ b/tests/hypothesis/test_basic_arithmetic.py
@@ -7,7 +7,7 @@ from hypothesis import given
 from hypothesis import strategies as st
 from numpy.testing import assert_allclose
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 @given(
diff --git a/tests/hypothesis/test_concat.py b/tests/hypothesis/test_concat.py
index ca86d99a..987edc21 100644
--- a/tests/hypothesis/test_concat.py
+++ b/tests/hypothesis/test_concat.py
@@ -8,7 +8,7 @@ import pytest
 from hypothesis import given
 from hypothesis import strategies as st
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 
diff --git a/tests/hypothesis/test_join.py b/tests/hypothesis/test_join.py
index 09386849..cf019583 100644
--- a/tests/hypothesis/test_join.py
+++ b/tests/hypothesis/test_join.py
@@ -7,7 +7,7 @@ from hypothesis import given
 from hypothesis import strategies as st
 from pandas.testing import assert_frame_equal
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 pl_version = parse_version(pl.__version__)
diff --git a/tests/series/arithmetic_test.py b/tests/series/arithmetic_test.py
index b84824ea..e5f9e908 100644
--- a/tests/series/arithmetic_test.py
+++ b/tests/series/arithmetic_test.py
@@ -4,7 +4,7 @@ from typing import Any
 
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 data = [1, 2, 3]
 
diff --git a/tests/series/array_dunder_test.py b/tests/series/array_dunder_test.py
index a9d3546a..4d419366 100644
--- a/tests/series/array_dunder_test.py
+++ b/tests/series/array_dunder_test.py
@@ -5,7 +5,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 
diff --git a/tests/series/cast_test.py b/tests/series/cast_test.py
index 3362291f..6fa375eb 100644
--- a/tests/series/cast_test.py
+++ b/tests/series/cast_test.py
@@ -7,7 +7,7 @@ import pyarrow as pa
 import pytest
 from polars.testing import assert_frame_equal
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 
diff --git a/tests/series/eq_ne_test.py b/tests/series/eq_ne_test.py
index c333c444..bdb5b721 100644
--- a/tests/series/eq_ne_test.py
+++ b/tests/series/eq_ne_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 data = [1, 2, 3]
 
diff --git a/tests/series/is_empty_test.py b/tests/series/is_empty_test.py
index 791dc732..122ab477 100644
--- a/tests/series/is_empty_test.py
+++ b/tests/series/is_empty_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_is_empty(constructor_series_with_pyarrow: Any) -> None:
diff --git a/tests/series/is_ordered_categorical_test.py b/tests/series/is_ordered_categorical_test.py
index e5f9d7c0..925989be 100644
--- a/tests/series/is_ordered_categorical_test.py
+++ b/tests/series/is_ordered_categorical_test.py
@@ -5,7 +5,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_is_ordered_categorical() -> None:
diff --git a/tests/series/shape_test.py b/tests/series/shape_test.py
index 64e3ddcf..03a303ee 100644
--- a/tests/series/shape_test.py
+++ b/tests/series/shape_test.py
@@ -5,7 +5,7 @@ import polars as pl
 import pyarrow as pa
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 @pytest.mark.parametrize(
diff --git a/tests/series/test_common.py b/tests/series/test_common.py
index 300f0c69..b7c7124d 100644
--- a/tests/series/test_common.py
+++ b/tests/series/test_common.py
@@ -11,7 +11,7 @@ import pytest
 from numpy.testing import assert_array_equal
 from pandas.testing import assert_series_equal
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 df_pandas = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
diff --git a/tests/series/to_frame_test.py b/tests/series/to_frame_test.py
index f139fec7..7c46af7a 100644
--- a/tests/series/to_frame_test.py
+++ b/tests/series/to_frame_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = [1, 2, 3]
diff --git a/tests/series/to_list_test.py b/tests/series/to_list_test.py
index b9de0fb5..997674db 100644
--- a/tests/series/to_list_test.py
+++ b/tests/series/to_list_test.py
@@ -1,6 +1,6 @@
 from typing import Any
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 data = [1, 2, 3]
 
diff --git a/tests/stable_api_test.py b/tests/stable_api_test.py
new file mode 100644
index 00000000..52cd1274
--- /dev/null
+++ b/tests/stable_api_test.py
@@ -0,0 +1,108 @@
+from typing import Any
+
+import polars as pl
+import pytest
+
+import narwhals as nw
+import narwhals.stable.v1 as nw_v1
+from tests.utils import compare_dicts
+
+
+def test_renamed_taxicab_norm(constructor: Any) -> None:
+    # Suppose we need to rename `_l1_norm` to `_taxicab_norm`.
+    # We need `narwhals.stable.v1` to stay stable. So, we
+    # make the change in `narwhals`, and then add the new method
+    # to the subclass of `Expr` in `narwhals.stable.v1`.
+    # Here, we check that anyone who wrote code using the old
+    # API will still be able to use it, without the main namespace
+    # getting cluttered by the new name.
+    df = nw.from_native(constructor({"a": [1, 2, 3, -4, 5]}))
+    result = df.with_columns(b=nw.col("a")._taxicab_norm())
+    expected = {"a": [1, 2, 3, -4, 5], "b": [15] * 5}
+    compare_dicts(result, expected)
+
+    with pytest.raises(AttributeError):
+        result = df.with_columns(b=nw.col("a")._l1_norm())  # type: ignore[attr-defined]
+
+    df = nw_v1.from_native(constructor({"a": [1, 2, 3, -4, 5]}))
+    # The newer `_taxicab_norm` can still work in the old API, no issue.
+    # It's new, so it couldn't be backwards-incompatible.
+    result = df.with_columns(b=nw_v1.col("a")._taxicab_norm())
+    expected = {"a": [1, 2, 3, -4, 5], "b": [15] * 5}
+    compare_dicts(result, expected)
+
+    # The older `_l1_norm` still works in the stable api
+    result = df.with_columns(b=nw_v1.col("a")._l1_norm())
+    compare_dicts(result, expected)
+
+
+def test_stable_api_completeness() -> None:
+    v_1_api = nw_v1.__all__
+    main_namespace_api = nw.__all__
+    extra = set(v_1_api).difference(main_namespace_api)
+    assert not extra
+    missing = set(main_namespace_api).difference(v_1_api).difference({"stable"})
+    assert not missing
+
+
+def test_stable_api_docstrings() -> None:
+    main_namespace_api = nw.__all__
+    for item in main_namespace_api:
+        if getattr(nw, item).__doc__ is None:
+            continue
+        assert (
+            getattr(nw_v1, item).__doc__.replace(
+                "import narwhals.stable.v1 as nw", "import narwhals as nw"
+            )
+            == getattr(nw, item).__doc__
+        )
+        assert (
+            getattr(nw, item).__doc__.replace(
+                "import narwhals as nw", "import narwhals.stable.v1 as nw"
+            )
+            == getattr(nw_v1, item).__doc__
+        )
+
+
+def test_dataframe_docstrings() -> None:
+    stable_df = nw_v1.from_native(pl.DataFrame())
+    df = nw.from_native(pl.DataFrame())
+    api = [i for i in df.__dir__() if not i.startswith("_")]
+    for item in api:
+        assert (
+            getattr(stable_df, item).__doc__.replace(
+                "import narwhals.stable.v1 as nw", "import narwhals as nw"
+            )
+            == getattr(df, item).__doc__
+        )
+
+
+def test_lazyframe_docstrings() -> None:
+    stable_df = nw_v1.from_native(pl.LazyFrame())
+    df = nw.from_native(pl.LazyFrame())
+    api = [i for i in df.__dir__() if not i.startswith("_")]
+    for item in api:
+        if item in ("schema", "columns"):
+            # to avoid performance warning
+            continue
+        assert (
+            getattr(stable_df, item).__doc__.replace(
+                "import narwhals.stable.v1 as nw", "import narwhals as nw"
+            )
+            == getattr(df, item).__doc__
+        )
+
+
+def test_series_docstrings() -> None:
+    stable_df = nw_v1.from_native(pl.Series(), series_only=True)
+    df = nw.from_native(pl.Series(), series_only=True)
+    api = [i for i in df.__dir__() if not i.startswith("_")]
+    for item in api:
+        if getattr(df, item).__doc__ is None:
+            continue
+        assert (
+            getattr(stable_df, item).__doc__.replace(
+                "import narwhals.stable.v1 as nw", "import narwhals as nw"
+            )
+            == getattr(df, item).__doc__
+        )
diff --git a/tests/test_group_by.py b/tests/test_group_by.py
index 7c3f4410..ee08d2cf 100644
--- a/tests/test_group_by.py
+++ b/tests/test_group_by.py
@@ -6,7 +6,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from tests.utils import compare_dicts
 
 data = {"a": [1, 1, 3], "b": [4, 4, 6], "c": [7.0, 8, 9]}
@@ -25,9 +25,9 @@ def test_group_by_complex() -> None:
         )
     compare_dicts(result, expected)
 
-    df = nw.from_native(df_lazy).lazy()
+    lf = nw.from_native(df_lazy).lazy()
     result = nw.to_native(
-        df.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")
+        lf.group_by("a").agg((nw.col("b") - nw.col("c").mean()).mean()).sort("a")
     )
     compare_dicts(result, expected)
 
diff --git a/tests/test_selectors.py b/tests/test_selectors.py
index d73bab0f..d7f9b8d4 100644
--- a/tests/test_selectors.py
+++ b/tests/test_selectors.py
@@ -6,7 +6,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.selectors import all
 from narwhals.selectors import boolean
 from narwhals.selectors import by_dtype
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 22606a27..f8b9b98e 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -4,7 +4,7 @@ import pytest
 from pandas.testing import assert_frame_equal
 from pandas.testing import assert_series_equal
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 
 
diff --git a/tests/tpch_q1_test.py b/tests/tpch_q1_test.py
index 2c4d48e7..d73400ee 100644
--- a/tests/tpch_q1_test.py
+++ b/tests/tpch_q1_test.py
@@ -8,7 +8,7 @@ import pandas as pd
 import polars as pl
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
diff --git a/tests/translate/from_native_pandas_test.py b/tests/translate/from_native_pandas_test.py
index d8725d15..f101354d 100644
--- a/tests/translate/from_native_pandas_test.py
+++ b/tests/translate/from_native_pandas_test.py
@@ -3,7 +3,7 @@ from typing import Any
 import pandas as pd
 import pytest
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_dupes() -> None:
diff --git a/tests/translate/get_native_namespace_test.py b/tests/translate/get_native_namespace_test.py
index b8381157..60b80a1d 100644
--- a/tests/translate/get_native_namespace_test.py
+++ b/tests/translate/get_native_namespace_test.py
@@ -2,7 +2,7 @@ import pandas as pd
 import polars as pl
 import pyarrow as pa
 
-import narwhals as nw
+import narwhals.stable.v1 as nw
 
 
 def test_native_namespace() -> None:
diff --git a/tests/translate/stable_narwhalify_test.py b/tests/translate/stable_narwhalify_test.py
new file mode 100644
index 00000000..61b2227e
--- /dev/null
+++ b/tests/translate/stable_narwhalify_test.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from contextlib import nullcontext as does_not_raise
+from typing import TYPE_CHECKING
+from typing import Any
+
+import pandas as pd
+import polars as pl
+import pytest
+
+import narwhals.stable.v1 as nw
+
+if TYPE_CHECKING:
+    from narwhals.typing import IntoDataFrameT
+
+data = {"a": [2, 3, 4]}
+
+
+def test_narwhalify() -> None:
+    @nw.narwhalify
+    def func(df: nw.DataFrame[IntoDataFrameT]) -> nw.DataFrame[IntoDataFrameT]:
+        return df.with_columns(nw.all() + 1)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+    result = func(df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+
+
+def test_narwhalify_method() -> None:
+    class Foo:
+        @nw.narwhalify
+        def func(
+            self, df: nw.DataFrame[IntoDataFrameT], a: int = 1
+        ) -> nw.DataFrame[IntoDataFrameT]:
+            return df.with_columns(nw.all() + a)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = Foo().func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+    result = Foo().func(a=1, df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+
+
+def test_narwhalify_method_called() -> None:
+    class Foo:
+        @nw.narwhalify
+        def func(
+            self, df: nw.DataFrame[IntoDataFrameT], a: int = 1
+        ) -> nw.DataFrame[IntoDataFrameT]:
+            return df.with_columns(nw.all() + a)
+
+    df = pd.DataFrame({"a": [1, 2, 3]})
+    result = Foo().func(df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+    result = Foo().func(df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+    result = Foo().func(a=1, df=df)
+    pd.testing.assert_frame_equal(result, pd.DataFrame(data))
+
+
+def test_narwhalify_method_invalid() -> None:
+    class Foo:
+        @nw.narwhalify(strict=True, eager_only=True)
+        def func(self) -> Foo:  # pragma: no cover
+            return self
+
+        @nw.narwhalify(strict=True, eager_only=True)
+        def fun2(self, df: Any) -> Any:  # pragma: no cover
+            return df
+
+    with pytest.raises(TypeError):
+        Foo().func()
+
+
+def test_narwhalify_invalid() -> None:
+    @nw.narwhalify(strict=True)
+    def func() -> None:  # pragma: no cover
+        return None
+
+    with pytest.raises(TypeError):
+        func()
+
+
+@pytest.mark.parametrize(
+    ("arg1", "arg2", "context"),
+    [
+        (pd.DataFrame(data), pd.Series(data["a"]), does_not_raise()),
+        (pl.DataFrame(data), pl.Series(data["a"]), does_not_raise()),
+        (
+            pd.DataFrame(data),
+            pl.DataFrame(data),
+            pytest.raises(
+                ValueError,
+                match="Found multiple backends. Make sure that all dataframe/series inputs come from the same backend.",
+            ),
+        ),
+        (
+            pl.DataFrame(data),
+            pd.Series(data["a"]),
+            pytest.raises(
+                ValueError,
+                match="Found multiple backends. Make sure that all dataframe/series inputs come from the same backend.",
+            ),
+        ),
+    ],
+)
+def test_narwhalify_backends(arg1: Any, arg2: Any, context: Any) -> None:
+    @nw.narwhalify
+    def func(
+        arg1: Any, arg2: Any, extra: int = 1
+    ) -> tuple[Any, Any, int]:  # pragma: no cover
+        return arg1, arg2, extra
+
+    with context:
+        func(arg1, arg2)