Repository: narwhals-dev/narwhals

Issue:
Inconsistency in using `DataFrame__getitem__` when pandas has non-string names
Example:
```python
import narwhals as nw
import pandas as pd
import polars as pl

@nw.narwhalify
def func(df):
    return df[df.columns[0]]
```

We then get:
```python
In [17]: func(pd.DataFrame({'a': [1, 2]}))
Out[17]:
0    1
1    2
Name: a, dtype: int64

In [18]: func(pl.DataFrame({'a': [1, 2]}))
Out[18]:
shape: (2,)
Series: 'a' [i64]
[
        1
        2
]

In [19]: func(pd.DataFrame({0: [1, 2]}))
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 1
----> 1 func(pd.DataFrame({0: [1, 2]}))

File ~/scratch/.venv/lib/python3.11/site-packages/narwhals/translate.py:530, in narwhalify.<locals>.decorator.<locals>.wrapper(*args, **kwargs)
    527     msg = "Found multiple backends. Make sure that all dataframe/series inputs come from the same backend."
    528     raise ValueError(msg)
--> 530 result = func(*args, **kwargs)
    532 return to_native(result, strict=strict)

Cell In[16], line 7, in func(df)
      5 @nw.narwhalify
      6 def func(df):
----> 7     return df[df.columns[0]]

File ~/scratch/.venv/lib/python3.11/site-packages/narwhals/dataframe.py:437, in DataFrame.__getitem__(self, item)
    435 else:
    436     msg = f"Expected str or slice, got: {type(item)}"
--> 437     raise TypeError(msg)

TypeError: Expected str or slice, got: <class 'int'>
```

Should using `__getitem__` to get a column even be allowed, or should we just use `get_column` for that?

Discussion:

Patch:
diff --git a/docs/api-reference/dataframe.md b/docs/api-reference/dataframe.md
index a0003729..9fa144ce 100644
--- a/docs/api-reference/dataframe.md
+++ b/docs/api-reference/dataframe.md
@@ -4,11 +4,13 @@
     handler: python
     options:
       members:
+        - __getitem__
         - clone
         - columns
         - drop
         - drop_nulls
         - filter
+        - get_column
         - group_by
         - head
         - is_duplicated
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index efee201d..9aab7738 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -63,6 +63,15 @@ class ArrowDataFrame:
             raise NotImplementedError(msg)
         return self._native_dataframe.to_pylist()  # type: ignore[no-any-return]
 
+    def get_column(self, name: str) -> ArrowSeries:
+        from narwhals._arrow.series import ArrowSeries
+
+        return ArrowSeries(
+            self._native_dataframe[name],
+            name=name,
+            backend_version=self._backend_version,
+        )
+
     @overload
     def __getitem__(self, item: str) -> ArrowSeries: ...
 
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 13315244..0dfd5d00 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -90,6 +90,15 @@ class PandasLikeDataFrame:
             backend_version=self._backend_version,
         )
 
+    def get_column(self, name: str) -> PandasLikeSeries:
+        from narwhals._pandas_like.series import PandasLikeSeries
+
+        return PandasLikeSeries(
+            self._native_dataframe.loc[:, name],
+            implementation=self._implementation,
+            backend_version=self._backend_version,
+        )
+
     @overload
     def __getitem__(self, item: str) -> PandasLikeSeries: ...
 
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index d0cadc80..b78d04ec 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -407,6 +407,54 @@ class DataFrame(BaseFrame[FrameT]):
         """
         return self._compliant_frame.shape  # type: ignore[no-any-return]
 
+    def get_column(self, name: str) -> Series:
+        """
+        Get a single column by name.
+
+        Notes:
+            Although `name` is typed as `str`, pandas does allow non-string column
+            names, and they will work when passed to this function if the
+            `narwhals.DataFrame` is backed by a pandas dataframe with non-string
+            columns. This function can only be used to extract a column by name, so
+            there is no risk of ambiguity.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = {"a": [1, 2], "b": [3, 4]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify(eager_only=True)
+            ... def func(df):
+            ...     name = df.columns[0]
+            ...     return df.get_column(name)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+            0    1
+            1    2
+            Name: a, dtype: int64
+            >>> func(df_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: 'a' [i64]
+            [
+                1
+                2
+            ]
+        """
+        from narwhals.series import Series
+
+        return Series(
+            self._compliant_frame.get_column(name),
+            backend_version=self._backend_version,
+            is_polars=self._is_polars,
+        )
+
     @overload
     def __getitem__(self, item: Sequence[int]) -> Series: ...
 
@@ -417,6 +465,49 @@ class DataFrame(BaseFrame[FrameT]):
     def __getitem__(self, item: slice) -> Self: ...
 
     def __getitem__(self, item: str | slice | Sequence[int]) -> Series | Self:
+        """
+        Extract column or slice of DataFrame.
+
+        Arguments:
+            item: how to slice dataframe:
+
+                - str: extract column
+                - slice or Sequence of integers: slice rows from dataframe.
+
+        Notes:
+            In contrast with Polars, pandas allows non-string column names.
+            If you don't know whether the column name you're trying to extract
+            is definitely a string (e.g. `df[df.columns[0]]`) then you should
+            use `DataFrame.get_column` instead.
+
+        Examples:
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> import narwhals as nw
+            >>> data = {"a": [1, 2], "b": [3, 4]}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            We define a library agnostic function:
+
+            >>> @nw.narwhalify(eager_only=True)
+            ... def func(df):
+            ...     return df["a"]
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)
+            0    1
+            1    2
+            Name: a, dtype: int64
+            >>> func(df_pl)  # doctest:+NORMALIZE_WHITESPACE
+            shape: (2,)
+            Series: 'a' [i64]
+            [
+                1
+                2
+            ]
+        """
         if isinstance(item, str):
             from narwhals.series import Series
 
diff --git a/pyproject.toml b/pyproject.toml
index 7dd4fe41..60b09c7f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,7 @@ lint.ignore = [
   'E501',
   'EM101',
   'FIX',
+  'ISC001',
   'PD901',  # This is a auxiliary library so dataframe variables have no concrete business meaning
   'PLR0911',
   'PLR0912',
diff --git a/utils/check_api_reference.py b/utils/check_api_reference.py
index 8fdf74d2..b6bede4b 100644
--- a/utils/check_api_reference.py
+++ b/utils/check_api_reference.py
@@ -51,7 +51,7 @@ if missing := set(top_level_functions).difference(documented):
     print("DataFrame: not documented")  # noqa: T201
     print(missing)  # noqa: T201
     ret = 1
-if extra := set(documented).difference(top_level_functions):
+if extra := set(documented).difference(top_level_functions).difference({"__getitem__"}):
     print("DataFrame: outdated")  # noqa: T201
     print(extra)  # noqa: T201
     ret = 1


Test patch:
diff --git a/tests/frame/get_column_test.py b/tests/frame/get_column_test.py
new file mode 100644
index 00000000..bbfab018
--- /dev/null
+++ b/tests/frame/get_column_test.py
@@ -0,0 +1,28 @@
+from typing import Any
+
+import pandas as pd
+import pytest
+
+import narwhals as nw
+
+
+def test_get_column(constructor_with_pyarrow: Any) -> None:
+    df = nw.from_native(
+        constructor_with_pyarrow({"a": [1, 2], "b": [3, 4]}), eager_only=True
+    )
+    result = df.get_column("a")
+    assert result.to_list() == [1, 2]
+    assert result.name == "a"
+    with pytest.raises(TypeError):
+        # Check that trying to get a column by position is disallowed here.
+        nw.from_native(df, eager_only=True).get_column(0)  # type: ignore[arg-type]
+
+
+def test_non_string_name() -> None:
+    df = pd.DataFrame({0: [1, 2]})
+    result = nw.from_native(df, eager_only=True).get_column(0)  # type: ignore[arg-type]
+    assert result.to_list() == [1, 2]
+    assert result.name == 0  # type: ignore[comparison-overlap]
+    with pytest.raises(TypeError, match="Expected str or slice"):
+        # Check that getitem would have raised
+        nw.from_native(df, eager_only=True)[0]  # type: ignore[call-overload]