Repository: narwhals-dev/narwhals (python)

Issue:
should `LazyFrame.schema` issue a performance warning?
`polars.LazyFrame.schema` issues a performance warning, advising to use `.collect_schema`

I think we should probably be doing the same? Else what's the point of us having both `LazyFrame.schema` and `LazyFrame.collect_schema`?

Pull request description:
enh: issue a performance warning for `LazyFrame.schema`
<!--
# Thanks for contributing a pull request! 
## Please make sure you see our contribution guidelines: https://github.com/narwhals-dev/narwhals/blob/main/CONTRIBUTING.md
-->

## What type of PR is this? (check all applicable)

- [ ] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [x] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #2783

## Checklist

- [x] Code follows style guide (ruff)
- [ ] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below


Patch:
diff --git a/README.md b/README.md
index 8ccea314..f4d7247e 100644
--- a/README.md
+++ b/README.md
@@ -175,6 +175,7 @@ Join the party!
 - [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 - [marimo](https://github.com/marimo-team/marimo)
 - [metalearners](https://github.com/Quantco/metalearners)
+- [mosaic](https://github.com/uwdata/mosaic)
 - [panel-graphic-walker](https://github.com/panel-extensions/panel-graphic-walker)
 - [plotly](https://plotly.com)
 - [pointblank](https://github.com/posit-dev/pointblank)
diff --git a/docs/ecosystem.md b/docs/ecosystem.md
index de79b18f..2f41c1a1 100644
--- a/docs/ecosystem.md
+++ b/docs/ecosystem.md
@@ -12,6 +12,7 @@ for their dataframe interoperability needs:
 * [hierarchicalforecast](https://github.com/Nixtla/hierarchicalforecast)
 * [marimo](https://github.com/marimo-team/marimo)
 * [metalearners](https://github.com/Quantco/metalearners)
+* [mosaic](https://github.com/uwdata/mosaic)
 * [panel-graphic-walker](https://github.com/panel-extensions/panel-graphic-walker)
 * [plotly](https://github.com/plotly/plotly.py)
 * [pointblank](https://github.com/posit-dev/pointblank)
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index 1d4543c6..f0d1c902 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -177,10 +177,7 @@ class PolarsBaseFrame(Generic[NativePolarsFrame]):
 
     @property
     def schema(self) -> dict[str, DType]:
-        return {
-            name: native_to_narwhals_dtype(dtype, self._version)
-            for name, dtype in self.native.schema.items()
-        }
+        return self.collect_schema()
 
     def join(
         self,
diff --git a/narwhals/_utils.py b/narwhals/_utils.py
index 10314654..2dc0243f 100644
--- a/narwhals/_utils.py
+++ b/narwhals/_utils.py
@@ -46,7 +46,12 @@ from narwhals.dependencies import (
     is_polars_series,
     is_pyarrow_chunked_array,
 )
-from narwhals.exceptions import ColumnNotFoundError, DuplicateError, InvalidOperationError
+from narwhals.exceptions import (
+    ColumnNotFoundError,
+    DuplicateError,
+    InvalidOperationError,
+    PerformanceWarning,
+)
 
 if TYPE_CHECKING:
     from collections.abc import Set  # noqa: PYI025
@@ -1458,6 +1463,15 @@ def issue_deprecation_warning(message: str, _version: str) -> None:
     warn(message=message, category=DeprecationWarning, stacklevel=find_stacklevel())
 
 
+def issue_performance_warning(message: str) -> None:
+    """Issue a performance warning.
+
+    Arguments:
+        message: The message associated with the warning.
+    """
+    warn(message=message, category=PerformanceWarning, stacklevel=find_stacklevel())
+
+
 def validate_strict_and_pass_though(
     strict: bool | None,  # noqa: FBT001
     pass_through: bool | None,  # noqa: FBT001
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 19e33670..48665e18 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -22,6 +22,7 @@ from narwhals._expression_parsing import (
 )
 from narwhals._utils import (
     Implementation,
+    Version,
     find_stacklevel,
     flatten,
     generate_repr,
@@ -32,6 +33,7 @@ from narwhals._utils import (
     is_sequence_like,
     is_slice_none,
     issue_deprecation_warning,
+    issue_performance_warning,
     supports_arrow_c_stream,
 )
 from narwhals.dependencies import get_polars, is_numpy_array
@@ -2479,9 +2481,15 @@ class LazyFrame(BaseFrame[FrameT]):
             >>> import duckdb
             >>> import narwhals as nw
             >>> lf_native = duckdb.sql("SELECT * FROM VALUES (1, 4.5), (3, 2.) df(a, b)")
-            >>> nw.from_native(lf_native).schema
+            >>> nw.from_native(lf_native).schema  # doctest:+SKIP
             Schema({'a': Int32, 'b': Decimal})
         """
+        if self._compliant_frame._version is not Version.V1:
+            msg = (
+                "Resolving the schema of a LazyFrame is a potentially expensive operation. "
+                "Use `LazyFrame.collect_schema()` to get the schema without this warning."
+            )
+            issue_performance_warning(msg)
         return super().schema
 
     def collect_schema(self) -> Schema:
diff --git a/narwhals/dtypes.py b/narwhals/dtypes.py
index 544614cf..47e79ed5 100644
--- a/narwhals/dtypes.py
+++ b/narwhals/dtypes.py
@@ -126,7 +126,7 @@ class Int128(SignedIntegerType):
 
         >>> s.cast(nw.Int128).dtype
         Int128
-        >>> nw.from_native(rel).schema["a"]
+        >>> nw.from_native(rel).collect_schema()["a"]
         Int128
     """
 
@@ -192,7 +192,7 @@ class UInt128(UnsignedIntegerType):
         >>> import duckdb
         >>> df_native = pd.DataFrame({"a": [2, 1, 3, 7]})
         >>> rel = duckdb.sql(" SELECT CAST (a AS UINT128) AS a FROM df_native ")
-        >>> nw.from_native(rel).schema["a"]
+        >>> nw.from_native(rel).collect_schema()["a"]
         UInt128
     """
 
@@ -745,7 +745,7 @@ class Time(TemporalType):
        Time
        >>> nw.from_native(ser_pa, series_only=True).dtype
        Time
-       >>> nw.from_native(rel).schema["t"]
+       >>> nw.from_native(rel).collect_schema()["t"]
        Time
     """
 
@@ -769,6 +769,6 @@ class Binary(DType):
         Binary
         >>> nw.from_native(ser_pa, series_only=True).dtype
         Binary
-        >>> nw.from_native(rel).schema["t"]
+        >>> nw.from_native(rel).collect_schema()["t"]
         Binary
     """
diff --git a/narwhals/exceptions.py b/narwhals/exceptions.py
index d18f5d0d..b6a445cb 100644
--- a/narwhals/exceptions.py
+++ b/narwhals/exceptions.py
@@ -103,3 +103,6 @@ class UnsupportedDTypeError(NarwhalsError):
 
 class NarwhalsUnstableWarning(UserWarning):
     """Warning issued when a method or function is considered unstable in the stable api."""
+
+
+class PerformanceWarning(Warning): ...


Test patch:
diff --git a/tests/dtypes_test.py b/tests/dtypes_test.py
index 4808e7c7..3a7039d7 100644
--- a/tests/dtypes_test.py
+++ b/tests/dtypes_test.py
@@ -10,6 +10,7 @@ import pyarrow as pa
 import pytest
 
 import narwhals as nw
+from narwhals.exceptions import PerformanceWarning
 from tests.utils import PANDAS_VERSION, POLARS_VERSION, PYARROW_VERSION
 
 if TYPE_CHECKING:
@@ -227,7 +228,7 @@ def test_huge_int() -> None:
     df = pl.DataFrame({"a": [1, 2, 3]})
 
     if POLARS_VERSION >= (1, 18):
-        result = nw.from_native(df.select(pl.col("a").cast(pl.Int128))).schema
+        result = nw.from_native(df.select(pl.col("a").cast(pl.Int128))).collect_schema()
         assert result["a"] == nw.Int128
     else:  # pragma: no cover
         # Int128 was not available yet
@@ -237,14 +238,15 @@ def test_huge_int() -> None:
         select cast(a as int128) as a
         from df
                      """)
-    result = nw.from_native(rel).schema
+    with pytest.warns(PerformanceWarning):
+        result = nw.from_native(rel).schema
     assert result["a"] == nw.Int128
 
     rel = duckdb.sql("""
         select cast(a as uint128) as a
         from df
                      """)
-    result = nw.from_native(rel).schema
+    result = nw.from_native(rel).collect_schema()
     assert result["a"] == nw.UInt128
 
     # TODO(unassigned): once other libraries support Int128/UInt128,
@@ -266,11 +268,13 @@ def test_decimal() -> None:
         select *
         from df
                      """)
-    result = nw.from_native(rel).schema
+    result = nw.from_native(rel).collect_schema()
     assert result["a"] == nw.Decimal
-    result = nw.from_native(df.to_pandas(use_pyarrow_extension_array=True)).schema
+    result = nw.from_native(
+        df.to_pandas(use_pyarrow_extension_array=True)
+    ).collect_schema()
     assert result["a"] == nw.Decimal
-    result = nw.from_native(df.to_arrow()).schema
+    result = nw.from_native(df.to_arrow()).collect_schema()
     assert result["a"] == nw.Decimal
 
 
diff --git a/tests/expr_and_series/cast_test.py b/tests/expr_and_series/cast_test.py
index 3ddf5875..70dd8075 100644
--- a/tests/expr_and_series/cast_test.py
+++ b/tests/expr_and_series/cast_test.py
@@ -300,7 +300,7 @@ def test_cast_struct(request: pytest.FixtureRequest, constructor: Constructor) -
                 F.col("a.rating").cast(T.DoubleType()).alias("rating"),
             ),
         )
-        assert nw.from_native(native_ldf).schema == nw.Schema(
+        assert nw.from_native(native_ldf).collect_schema() == nw.Schema(
             {
                 "a": nw.Struct(
                     [nw.Field("movie ", nw.String()), nw.Field("rating", nw.Float64())]
diff --git a/tests/frame/schema_test.py b/tests/frame/schema_test.py
index aa45595f..e749d274 100644
--- a/tests/frame/schema_test.py
+++ b/tests/frame/schema_test.py
@@ -8,6 +8,7 @@ import pandas as pd
 import pytest
 
 import narwhals as nw
+from narwhals.exceptions import PerformanceWarning
 from tests.utils import PANDAS_VERSION
 
 if TYPE_CHECKING:
@@ -248,7 +249,7 @@ def test_nested_dtypes() -> None:
         schema_overrides={"b": pl.Array(pl.Int64, 2)},
     ).to_pandas(use_pyarrow_extension_array=True)
     nwdf: nw.DataFrame[Any] | nw.LazyFrame[Any] = nw.from_native(df_pd)
-    assert nwdf.schema == {
+    assert nwdf.collect_schema() == {
         "a": nw.List(nw.Int64),
         "b": nw.Array(nw.Int64, 2),
         "c": nw.Struct({"a": nw.Int64}),
@@ -258,7 +259,7 @@ def test_nested_dtypes() -> None:
         schema_overrides={"b": pl.Array(pl.Int64, 2)},
     )
     nwdf = nw.from_native(df_pl)
-    assert nwdf.schema == {
+    assert nwdf.collect_schema() == {
         "a": nw.List(nw.Int64),
         "b": nw.Array(nw.Int64, 2),
         "c": nw.Struct({"a": nw.Int64}),
@@ -269,14 +270,14 @@ def test_nested_dtypes() -> None:
         schema_overrides={"b": pl.Array(pl.Int64, 2)},
     ).to_arrow()
     nwdf = nw.from_native(df_pa)
-    assert nwdf.schema == {
+    assert nwdf.collect_schema() == {
         "a": nw.List(nw.Int64),
         "b": nw.Array(nw.Int64, 2),
         "c": nw.Struct({"a": nw.Int64, "b": nw.String, "c": nw.Float64}),
     }
     rel = duckdb.sql("select * from df_pa")
     nwdf = nw.from_native(rel)
-    assert nwdf.schema == {
+    assert nwdf.collect_schema() == {
         "a": nw.List(nw.Int64),
         "b": nw.Array(nw.Int64, 2),
         "c": nw.Struct({"a": nw.Int64, "b": nw.String, "c": nw.Float64}),
@@ -296,7 +297,10 @@ def test_nested_dtypes_ibis() -> None:  # pragma: no cover
     )
     tbl = ibis.memtable(df[["a", "c"]])
     nwdf = nw.from_native(tbl)
-    assert nwdf.schema == {"a": nw.List(nw.Int64), "c": nw.Struct({"a": nw.Int64})}
+    assert nwdf.collect_schema() == {
+        "a": nw.List(nw.Int64),
+        "c": nw.Struct({"a": nw.Int64}),
+    }
 
 
 @pytest.mark.skipif(PANDAS_VERSION < (2, 2, 0), reason="too old for pyarrow types")
@@ -314,7 +318,9 @@ def test_nested_dtypes_dask() -> None:
         ).to_pandas(use_pyarrow_extension_array=True)
     )
     nwdf = nw.from_native(df)
-    assert nwdf.schema == {
+    with pytest.warns(PerformanceWarning):
+        result = nwdf.schema
+    assert result == {
         "a": nw.List(nw.Int64),
         "b": nw.Array(nw.Int64, 2),
         "c": nw.Struct({"a": nw.Int64}),
diff --git a/tests/v1_test.py b/tests/v1_test.py
index 2ce4c79e..d243517f 100644
--- a/tests/v1_test.py
+++ b/tests/v1_test.py
@@ -497,7 +497,7 @@ def test_dtypes() -> None:
     dtype = df.collect_schema()["b"]
     assert dtype in {nw_v1.Datetime}
     assert isinstance(dtype, nw_v1.Datetime)
-    dtype = df.collect_schema()["c"]
+    dtype = df.lazy().schema["c"]
     assert dtype in {nw_v1.Duration}
     assert isinstance(dtype, nw_v1.Duration)