Repository: narwhals-dev/narwhals

Issue:
Add `narwhals.maybe_convert_dtypes`
pandas-like libraries have `convert_dtypes`: https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.convert_dtypes.html

People use this to "promote" types - e.g., to promote all types to use the pyarrow-backed ones.

Polars doesn't have anything like this, because each type is just self-standing. E.g. `pl.Int64` is just `pl.Int64`, there aren't any variants of it

So, the task here is to add a method `maybe_convert_dtypes`, which for pandas-like calls `convert_dtypes`, and for the rest is a no-op, just like how Narwhals already has other `maybe_*` funcs: https://narwhals-dev.github.io/narwhals/api-reference/narwhals/#narwhals.maybe_set_index

Discussion:
MarcoGorelli: looks like pandas' series repr was a bit different in older versions (you can reproduce this locally by pip-installing the pandas version from the minimum-versions ci job) for string dtype

maybe only include bool and int in the example?
DeaMariaLeon: Thanks @MarcoGorelli 

Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 863d3ad9..50555f31 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -14,6 +14,7 @@ Here are the top-level functions available in Narwhals.
         - len
         - maybe_align_index
         - maybe_set_index
+        - maybe_convert_dtypes
         - max
         - mean
         - min
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 7e4700d4..a6927b8d 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -30,6 +30,7 @@ from narwhals.translate import from_native
 from narwhals.translate import get_native_namespace
 from narwhals.translate import to_native
 from narwhals.utils import maybe_align_index
+from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
 __version__ = "0.9.2"
@@ -40,6 +41,7 @@ __all__ = [
     "to_native",
     "from_native",
     "maybe_align_index",
+    "maybe_convert_dtypes",
     "maybe_set_index",
     "get_native_namespace",
     "all",
diff --git a/narwhals/utils.py b/narwhals/utils.py
index a17b6cf4..77c1307b 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -214,3 +214,41 @@ def maybe_set_index(df: T, column_names: str | list[str]) -> T:
             )
         )
     return df
+
+
+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 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
+    """
+    from narwhals._pandas_like.dataframe import PandasDataFrame
+    from narwhals.dataframe import DataFrame
+
+    df_any = cast(Any, df)
+    if isinstance(getattr(df_any, "_dataframe", None), PandasDataFrame):
+        return DataFrame(  # type: ignore[return-value]
+            df_any._dataframe._from_dataframe(
+                df_any._dataframe._dataframe.convert_dtypes(*args, **kwargs)
+            )
+        )
+    return df


Test patch:
diff --git a/tests/utils_test.py b/tests/utils_test.py
index 228e7ec7..91a56792 100644
--- a/tests/utils_test.py
+++ b/tests/utils_test.py
@@ -60,3 +60,20 @@ def test_maybe_set_index_polars() -> None:
     df = nw.from_native(pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]}))
     result = nw.maybe_set_index(df, "b")
     assert result is df
+
+
+def test_maybe_convert_dtypes_pandas() -> None:
+    import numpy as np
+
+    df = nw.from_native(pd.DataFrame({"a": [1, np.nan]}, dtype=np.dtype("float64")))
+    result = nw.to_native(nw.maybe_convert_dtypes(df))
+    expected = pd.DataFrame({"a": [1, pd.NA]}, dtype="Int64")
+    pd.testing.assert_frame_equal(result, expected)
+
+
+def test_maybe_convert_dtypes_polars() -> None:
+    import numpy as np
+
+    df = nw.from_native(pl.DataFrame({"a": [1.1, np.nan]}))
+    result = nw.maybe_convert_dtypes(df)
+    assert result is df