Repository: narwhals-dev/narwhals

Issue:
[Bug]: Subsetting 0 rows or 0 columns returns returns unexpected behavior
### Describe the bug

Unexpected row subset behaviors:
* When performing an empty row subset on pandas, it returns an empty column data frame. This does not happen with polars
* When performing an empty row and an column slice subset on both pandas an polars, an error is thrown: `TypeError: Expected slice of integers or strings, got: <class 'slice'>`

Unexpected column subset behaviors:
* When performing an empty column subset on pandas, it returns an data frame with `nrow` rows and 0 columns. This does not happen with polars.

Unexpected row/column subset behavior
* When performing an empty slice row and empty slice column subset on both pandas an polars, an error is thrown: `TypeError: Expected slice of integers or strings, got: <class 'slice'>`


Links to docs where examples were inspired from: 
https://narwhals-dev.github.io/narwhals/api-reference/dataframe/#narwhals.dataframe.DataFrame.__getitem__

(Other data types are untested)

### Steps or code to reproduce the bug

```python
import narwhals as nw
import pandas as pd
import polars as pl

DATA={ "x": [1,2,3] }
pd_df = nw.from_native(pd.DataFrame(DATA), eager_only=True)
pl_df = nw.from_native(pl.DataFrame(DATA), eager_only=True)

print("nw row subset.\nExpecting (0, 3)")

# This should return 0 rows and all columns. 
# Currently it returns all rows and 0 columns
print("pd_df[[]].shape (bad):", pd_df[[]].shape)
print("pl_df[[]].shape (good!):", pl_df[[]].shape)

# This should return 0 rows and all columns. 
# Currently it raises an error: 
# TypeError: Expected slice of integers or strings, got: <class 'slice'>
try:
    pd_df[[], :].shape
except TypeError as e:
    print("pd_df[[], :].shape Error:", str(e))
try:
    pl_df[[], :].shape
except TypeError as e:
    print("pl_df[[], :].shape Error:", str(e))

# Work around: 0 rows and all columns
print("pd_df[[], pd_df.columns].shape", pd_df[[], pd_df.columns].shape)
print("pl_df[[], pl_df.columns].shape", pl_df[[], pl_df.columns].shape)


print("-----------------")
print("nw col subset.\nExpecting (0, 0) because if there are no Series objects, then there are no rows")

print("pd_df[:, []].shape (bad):", pd_df[:, []].shape)
print("pl_df[:, []].shape (good!):", pl_df[:, []].shape)

try:
    pd_df[:, :].shape
except TypeError as e:
    print("pd_df[:, :].shape Error:", str(e))
try:
    pl_df[:, :].shape
except TypeError as e:
    print("pl_df[:, :].shape Error:", str(e))

print("pd_df[[], []].shape (good!):", pd_df[[], []].shape)
print("pl_df[[], []].shape (good!):", pl_df[[], []].shape)

print("-----------------")
print("nw.show_versions()")
nw.show_versions()
```

### Expected results

* These row subsets should always return a `.shape` of `(0, COL_LEN)`.
	* `df[[]].shape`; (broken for pandas)
	* `df[[], :].shape`
* These column subsets above should always return a `.shape` of `(0, 0)`. If there are no Series objects to hold the data, then there should be 0 rows.
	* `df[:, []].shape`; (broken for pandas)
	* `df[:, []].shape`
* I'd expect `df[:, :]` to return `df`

### Actual results

```python
nw row subset.
Expecting (0, 3)
pd_df[[]].shape (bad): (3, 0)
pl_df[[]].shape (good!): (0, 1)
pd_df[[], :].shape Error: Expected slice of integers or strings, got: <class 'slice'>
pl_df[[], :].shape Error: Expected slice of integers or strings, got: <class 'slice'>
pd_df[[], pd_df.columns].shape (0, 1)
pl_df[[], pl_df.columns].shape (0, 1)
-----------------
nw col subset.
Expecting (0, 0) because if there are no Series object, then there are no rows
pd_df[:, []].shape (bad): (3, 0)
pl_df[:, []].shape (good!): (0, 0)
pd_df[:, :].shape Error: Expected slice of integers or strings, got: <class 'slice'>
pl_df[:, :].shape Error: Expected slice of integers or strings, got: <class 'slice'>
pd_df[[], []].shape (good!): (0, 0)
pl_df[[], []].shape (good!): (0, 0)
```

### Please run narwhals.show_version() and enter the output below.

```shell
System:
    python: 3.12.4 (main, Jun  6 2024, 18:26:44) [Clang 15.0.0 (clang-1500.3.9.4)]
executable: [/path/to/python](https://file+.vscode-resource.vscode-cdn.net/path/to/python)
   machine: macOS-14.6.1-arm64-arm-64bit

Python dependencies:
     narwhals: 1.8.1
       pandas: 2.2.2
       polars: 1.5.0
         cudf: 
        modin: 
      pyarrow: 
        numpy: 2.1.0
```



### Relevant log output

_No response_

Discussion:

Patch:
diff --git a/.github/workflows/check_docs_build.yml b/.github/workflows/check_docs_build.yml
index c59e67f4..1602bff4 100644
--- a/.github/workflows/check_docs_build.yml
+++ b/.github/workflows/check_docs_build.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/check_tpch_queries.yml b/.github/workflows/check_tpch_queries.yml
index 509ebb95..f1bbde92 100644
--- a/.github/workflows/check_tpch_queries.yml
+++ b/.github/workflows/check_tpch_queries.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/extremes.yml b/.github/workflows/extremes.yml
index cec9d32d..f11a4f4b 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -46,7 +46,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -75,7 +75,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -104,7 +104,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index aeed2538..f2ed79c6 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -153,7 +153,7 @@ listed above in [Working with local development environment](#working-with-local
 ## How it works
 
 If Narwhals looks like underwater unicorn magic to you, then please read
-[how it works](https://narwhals-dev.github.io/narwhals/how-it-works/).
+[how it works](https://narwhals-dev.github.io/narwhals/how_it_works/).
 
 ## Imports
 
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 428e83e3..5b74ddb7 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -9,7 +9,8 @@ from typing import Sequence
 from typing import overload
 
 from narwhals._arrow.utils import broadcast_series
-from narwhals._arrow.utils import convert_slice_to_nparray
+from narwhals._arrow.utils import convert_str_slice_to_int_slice
+from narwhals._arrow.utils import select_rows
 from narwhals._arrow.utils import translate_dtype
 from narwhals._arrow.utils import validate_dataframe_comparand
 from narwhals._expression_parsing import evaluate_into_exprs
@@ -18,6 +19,7 @@ from narwhals.dependencies import is_numpy_array
 from narwhals.utils import Implementation
 from narwhals.utils import flatten
 from narwhals.utils import generate_unique_token
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -121,6 +123,9 @@ class ArrowDataFrame:
     @overload
     def __getitem__(self, item: slice) -> ArrowDataFrame: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> ArrowDataFrame: ...
+
     def __getitem__(
         self,
         item: str
@@ -128,7 +133,8 @@ class ArrowDataFrame:
         | Sequence[int]
         | Sequence[str]
         | tuple[Sequence[int], str | int]
-        | tuple[slice, str | int],
+        | tuple[slice, str | int]
+        | tuple[slice, slice],
     ) -> ArrowSeries | ArrowDataFrame:
         if isinstance(item, str):
             from narwhals._arrow.series import ArrowSeries
@@ -141,33 +147,19 @@ class ArrowDataFrame:
         elif (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (list, tuple))
+            and is_sequence_but_not_str(item[1])
         ):
-            if item[0] == slice(None):
-                selected_rows = self._native_frame
-            else:
-                range_ = convert_slice_to_nparray(
-                    num_rows=len(self._native_frame), rows_slice=item[0]
-                )
-                selected_rows = self._native_frame.take(range_)
-
+            if len(item[1]) == 0:
+                # Return empty dataframe
+                return self._from_native_frame(self._native_frame.slice(0, 0).select([]))
+            selected_rows = select_rows(self._native_frame, item[0])
             return self._from_native_frame(selected_rows.select(item[1]))
 
         elif isinstance(item, tuple) and len(item) == 2:
             if isinstance(item[1], slice):
                 columns = self.columns
                 if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                    start = (
-                        columns.index(item[1].start)
-                        if item[1].start is not None
-                        else None
-                    )
-                    stop = (
-                        columns.index(item[1].stop) + 1
-                        if item[1].stop is not None
-                        else None
-                    )
-                    step = item[1].step
+                    start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
                     return self._from_native_frame(
                         self._native_frame.take(item[0]).select(columns[start:stop:step])
                     )
@@ -192,11 +184,9 @@ class ArrowDataFrame:
                     name=col_name,
                     backend_version=self._backend_version,
                 )
-            range_ = convert_slice_to_nparray(
-                num_rows=len(self._native_frame), rows_slice=item[0]
-            )
+            selected_rows = select_rows(self._native_frame, item[0])
             return ArrowSeries(
-                self._native_frame[col_name].take(range_),
+                selected_rows[col_name],
                 name=col_name,
                 backend_version=self._backend_version,
             )
@@ -205,15 +195,27 @@ class ArrowDataFrame:
             if item.step is not None and item.step != 1:
                 msg = "Slicing with step is not supported on PyArrow tables"
                 raise NotImplementedError(msg)
+            columns = self.columns
+            if isinstance(item.start, str) or isinstance(item.stop, str):
+                start, stop, step = convert_str_slice_to_int_slice(item, columns)
+                return self._from_native_frame(
+                    self._native_frame.select(columns[start:stop:step])
+                )
             start = item.start or 0
-            stop = item.stop or len(self._native_frame)
+            stop = item.stop if item.stop is not None else len(self._native_frame)
             return self._from_native_frame(
-                self._native_frame.slice(item.start, stop - start),
+                self._native_frame.slice(start, stop - start),
             )
 
         elif isinstance(item, Sequence) or (is_numpy_array(item) and item.ndim == 1):
-            if isinstance(item, Sequence) and all(isinstance(x, str) for x in item):
+            if (
+                isinstance(item, Sequence)
+                and all(isinstance(x, str) for x in item)
+                and len(item) > 0
+            ):
                 return self._from_native_frame(self._native_frame.select(item))
+            if isinstance(item, Sequence) and len(item) == 0:
+                return self._from_native_frame(self._native_frame.slice(0, 0))
             return self._from_native_frame(self._native_frame.take(item))
 
         else:  # pragma: no cover
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index a2a45586..29bc3564 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -8,6 +8,8 @@ from narwhals import dtypes
 from narwhals.utils import isinstance_or_issubclass
 
 if TYPE_CHECKING:
+    import pyarrow as pa
+
     from narwhals._arrow.series import ArrowSeries
 
 
@@ -286,3 +288,23 @@ def convert_slice_to_nparray(
         return np.arange(num_rows)[rows_slice]
     else:
         return rows_slice
+
+
+def select_rows(table: pa.Table, rows: Any) -> pa.Table:
+    if isinstance(rows, slice) and rows == slice(None):
+        selected_rows = table
+    elif isinstance(rows, Sequence) and not rows:
+        selected_rows = table.slice(0, 0)
+    else:
+        range_ = convert_slice_to_nparray(num_rows=len(table), rows_slice=rows)
+        selected_rows = table.take(range_)
+    return selected_rows
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: list[str]
+) -> tuple[int | None, int | None, int | None]:
+    start = columns.index(str_slice.start) if str_slice.start is not None else None
+    stop = columns.index(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 62067069..53481c2f 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -11,6 +11,7 @@ from typing import overload
 from narwhals._expression_parsing import evaluate_into_exprs
 from narwhals._pandas_like.expr import PandasLikeExpr
 from narwhals._pandas_like.utils import broadcast_series
+from narwhals._pandas_like.utils import convert_str_slice_to_int_slice
 from narwhals._pandas_like.utils import create_native_series
 from narwhals._pandas_like.utils import horizontal_concat
 from narwhals._pandas_like.utils import translate_dtype
@@ -22,6 +23,7 @@ from narwhals.dependencies import is_numpy_array
 from narwhals.utils import Implementation
 from narwhals.utils import flatten
 from narwhals.utils import generate_unique_token
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -119,6 +121,9 @@ class PandasLikeDataFrame:
     @overload
     def __getitem__(self, item: slice) -> PandasLikeDataFrame: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(
         self,
         item: str
@@ -126,7 +131,8 @@ class PandasLikeDataFrame:
         | slice
         | Sequence[int]
         | Sequence[str]
-        | tuple[Sequence[int], str | int],
+        | tuple[Sequence[int], str | int]
+        | tuple[slice, slice],
     ) -> PandasLikeSeries | PandasLikeDataFrame:
         if isinstance(item, str):
             from narwhals._pandas_like.series import PandasLikeSeries
@@ -140,16 +146,19 @@ class PandasLikeDataFrame:
         elif (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (tuple, list))
+            and is_sequence_but_not_str(item[1])
         ):
+            if len(item[1]) == 0:
+                # Return empty dataframe
+                return self._from_native_frame(self._native_frame.__class__())
             if all(isinstance(x, int) for x in item[1]):
                 return self._from_native_frame(self._native_frame.iloc[item])
             if all(isinstance(x, str) for x in item[1]):
-                item = (
+                indexer = (
                     item[0],
                     self._native_frame.columns.get_indexer(item[1]),
                 )
-                return self._from_native_frame(self._native_frame.iloc[item])
+                return self._from_native_frame(self._native_frame.iloc[indexer])
             msg = (
                 f"Expected sequence str or int, got: {type(item[1])}"  # pragma: no cover
             )
@@ -158,15 +167,7 @@ class PandasLikeDataFrame:
         elif isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
             columns = self._native_frame.columns
             if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                start = (
-                    columns.get_loc(item[1].start) if item[1].start is not None else None
-                )
-                stop = (
-                    columns.get_loc(item[1].stop) + 1
-                    if item[1].stop is not None
-                    else None
-                )
-                step = item[1].step
+                start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
                 return self._from_native_frame(
                     self._native_frame.iloc[item[0], slice(start, stop, step)]
                 )
@@ -197,13 +198,21 @@ class PandasLikeDataFrame:
                 backend_version=self._backend_version,
             )
 
-        elif isinstance(item, (slice, Sequence)) or (
-            is_numpy_array(item) and item.ndim == 1
-        ):
-            if isinstance(item, Sequence) and all(isinstance(x, str) for x in item):
+        elif is_sequence_but_not_str(item) or (is_numpy_array(item) and item.ndim == 1):
+            if all(isinstance(x, str) for x in item) and len(item) > 0:
                 return self._from_native_frame(self._native_frame.loc[:, item])
             return self._from_native_frame(self._native_frame.iloc[item])
 
+        elif isinstance(item, slice):
+            if isinstance(item.start, str) or isinstance(item.stop, str):
+                start, stop, step = convert_str_slice_to_int_slice(
+                    item, self._native_frame.columns
+                )
+                return self._from_native_frame(
+                    self._native_frame.iloc[:, slice(start, stop, step)]
+                )
+            return self._from_native_frame(self._native_frame.iloc[item])
+
         else:  # pragma: no cover
             msg = f"Expected str or slice, got: {type(item)}"
             raise TypeError(msg)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 9e1d79ce..bdcb71ef 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
     from narwhals.dtypes import DType
 
     ExprT = TypeVar("ExprT", bound=PandasLikeExpr)
+    import pandas as pd
 
 
 def validate_column_comparand(index: Any, other: Any) -> Any:
@@ -497,3 +498,12 @@ def int_dtype_mapper(dtype: Any) -> str:
     if str(dtype).lower() != str(dtype):  # pragma: no cover
         return "Int64"
     return "int64"
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: pd.Index
+) -> tuple[int | None, int | None, int | None]:
+    start = columns.get_loc(str_slice.start) if str_slice.start is not None else None
+    stop = columns.get_loc(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index c5164147..9f30382d 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -4,10 +4,12 @@ from typing import TYPE_CHECKING
 from typing import Any
 
 from narwhals._polars.namespace import PolarsNamespace
+from narwhals._polars.utils import convert_str_slice_to_int_slice
 from narwhals._polars.utils import extract_args_kwargs
 from narwhals._polars.utils import translate_dtype
 from narwhals.dependencies import get_polars
 from narwhals.utils import Implementation
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_columns_to_drop
 
 if TYPE_CHECKING:
@@ -84,38 +86,52 @@ class PolarsDataFrame:
         return self._native_frame.shape  # type: ignore[no-any-return]
 
     def __getitem__(self, item: Any) -> Any:
-        if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
+        if self._backend_version >= (0, 20, 30):
+            return self._from_native_object(self._native_frame.__getitem__(item))
+        else:  # pragma: no cover
             # TODO(marco): we can delete this branch after Polars==0.20.30 becomes the minimum
             # Polars version we support
             columns = self.columns
-            if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
-                start = (
-                    columns.index(item[1].start) if item[1].start is not None else None
-                )
-                stop = (
-                    columns.index(item[1].stop) + 1 if item[1].stop is not None else None
-                )
-                step = item[1].step
-                return self._from_native_frame(
-                    self._native_frame.select(columns[start:stop:step]).__getitem__(
-                        item[0]
+            if isinstance(item, tuple) and len(item) == 2 and isinstance(item[1], slice):
+                if isinstance(item[1].start, str) or isinstance(item[1].stop, str):
+                    start, stop, step = convert_str_slice_to_int_slice(item[1], columns)
+                    return self._from_native_frame(
+                        self._native_frame.select(columns[start:stop:step]).__getitem__(
+                            item[0]
+                        )
                     )
-                )
-            if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                if isinstance(item[1].start, int) or isinstance(item[1].stop, int):
+                    return self._from_native_frame(
+                        self._native_frame.select(
+                            columns[item[1].start : item[1].stop : item[1].step]
+                        ).__getitem__(item[0])
+                    )
+                msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
+                raise TypeError(msg)  # pragma: no cover
+            pl = get_polars()
+            if (
+                isinstance(item, tuple)
+                and (len(item) == 2)
+                and is_sequence_but_not_str(item[1])
+                and (len(item[1]) == 0)
+            ):
+                result = self._native_frame.select(item[1])
+            elif isinstance(item, slice) and (
+                isinstance(item.start, str) or isinstance(item.stop, str)
+            ):
+                start, stop, step = convert_str_slice_to_int_slice(item, columns)
                 return self._from_native_frame(
-                    self._native_frame.select(
-                        columns[item[1].start : item[1].stop : item[1].step]
-                    ).__getitem__(item[0])
+                    self._native_frame.select(columns[start:stop:step])
                 )
-            msg = f"Expected slice of integers or strings, got: {type(item[1])}"  # pragma: no cover
-            raise TypeError(msg)  # pragma: no cover
-        pl = get_polars()
-        result = self._native_frame.__getitem__(item)
-        if isinstance(result, pl.Series):
-            from narwhals._polars.series import PolarsSeries
-
-            return PolarsSeries(result, backend_version=self._backend_version)
-        return self._from_native_object(result)
+            elif is_sequence_but_not_str(item) and (len(item) == 0):
+                result = self._native_frame.slice(0, 0)
+            else:
+                result = self._native_frame.__getitem__(item)
+            if isinstance(result, pl.Series):
+                from narwhals._polars.series import PolarsSeries
+
+                return PolarsSeries(result, backend_version=self._backend_version)
+            return self._from_native_object(result)
 
     def get_column(self, name: str) -> Any:
         from narwhals._polars.series import PolarsSeries
diff --git a/narwhals/_polars/utils.py b/narwhals/_polars/utils.py
index 51f0b189..46f399a8 100644
--- a/narwhals/_polars/utils.py
+++ b/narwhals/_polars/utils.py
@@ -110,3 +110,12 @@ def narwhals_to_native_dtype(dtype: dtypes.DType | type[dtypes.DType]) -> Any:
     if dtype == dtypes.Date:
         return pl.Date()
     return pl.Unknown()  # pragma: no cover
+
+
+def convert_str_slice_to_int_slice(
+    str_slice: slice, columns: list[str]
+) -> tuple[int | None, int | None, int | None]:  # pragma: no cover
+    start = columns.index(str_slice.start) if str_slice.start is not None else None
+    stop = columns.index(str_slice.stop) + 1 if str_slice.stop is not None else None
+    step = str_slice.step
+    return (start, stop, step)
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index b51d53ba..72ecc36b 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -15,6 +15,7 @@ from narwhals.dependencies import get_polars
 from narwhals.dependencies import is_numpy_array
 from narwhals.schema import Schema
 from narwhals.utils import flatten
+from narwhals.utils import is_sequence_but_not_str
 from narwhals.utils import parse_version
 
 if TYPE_CHECKING:
@@ -624,6 +625,9 @@ class DataFrame(BaseFrame[FrameT]):
     @overload
     def __getitem__(self, item: slice) -> Self: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(
         self,
         item: str
@@ -632,7 +636,8 @@ class DataFrame(BaseFrame[FrameT]):
         | Sequence[str]
         | tuple[Sequence[int], str | int]
         | tuple[slice, str | int]
-        | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice],
+        | tuple[slice | Sequence[int], Sequence[int] | Sequence[str] | slice]
+        | tuple[slice, slice],
     ) -> Series | Self:
         """
         Extract column or slice of DataFrame.
@@ -715,8 +720,12 @@ class DataFrame(BaseFrame[FrameT]):
         if (
             isinstance(item, tuple)
             and len(item) == 2
-            and isinstance(item[1], (list, tuple, slice))
+            and (is_sequence_but_not_str(item[1]) or isinstance(item[1], slice))
         ):
+            if item[1] == slice(None) and item[0] == slice(None):
+                return self
+            if item[1] == slice(None):
+                return self._from_compliant_dataframe(self._compliant_frame[item[0]])
             return self._from_compliant_dataframe(self._compliant_frame[item])
         if isinstance(item, str) or (isinstance(item, tuple) and len(item) == 2):
             from narwhals.series import Series
@@ -726,8 +735,10 @@ class DataFrame(BaseFrame[FrameT]):
                 level=self._level,
             )
 
-        elif isinstance(item, (Sequence, slice)) or (
-            is_numpy_array(item) and item.ndim == 1
+        elif (
+            is_sequence_but_not_str(item)
+            or isinstance(item, slice)
+            or (is_numpy_array(item) and item.ndim == 1)
         ):
             return self._from_compliant_dataframe(self._compliant_frame[item])
 
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index fa98fd96..1f103aae 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -103,6 +103,9 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     @overload
     def __getitem__(self, item: slice) -> Self: ...
 
+    @overload
+    def __getitem__(self, item: tuple[slice, slice]) -> Self: ...
+
     def __getitem__(self, item: Any) -> Any:
         return _stableify(super().__getitem__(item))
 
diff --git a/narwhals/utils.py b/narwhals/utils.py
index ec3c722d..f9d34099 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
     from types import ModuleType
 
     from typing_extensions import Self
+    from typing_extensions import TypeGuard
 
     from narwhals.dataframe import BaseFrame
     from narwhals.series import Series
@@ -461,3 +462,7 @@ def parse_columns_to_drop(
     else:
         to_drop = list(cols.intersection(set(to_drop)))
     return to_drop
+
+
+def is_sequence_but_not_str(sequence: Any) -> TypeGuard[Sequence[Any]]:
+    return isinstance(sequence, Sequence) and not isinstance(sequence, str)


Test patch:
diff --git a/.github/workflows/downstream_tests.yml b/.github/workflows/downstream_tests.yml
index 26e9edf7..0dcb7820 100644
--- a/.github/workflows/downstream_tests.yml
+++ b/.github/workflows/downstream_tests.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -63,7 +63,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml
index 39b5c91b..179891dd 100644
--- a/.github/workflows/pytest.yml
+++ b/.github/workflows/pytest.yml
@@ -19,7 +19,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -46,7 +46,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
@@ -75,7 +75,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/.github/workflows/random_ci_pytest.yml b/.github/workflows/random_ci_pytest.yml
index b029aba3..addc73ed 100644
--- a/.github/workflows/random_ci_pytest.yml
+++ b/.github/workflows/random_ci_pytest.yml
@@ -17,7 +17,7 @@ jobs:
         with:
           python-version: ${{ matrix.python-version }}
       - name: Install uv
-        uses: astral-sh/setup-uv@v2
+        uses: astral-sh/setup-uv@v3
         with:
           enable-cache: "true"
           cache-suffix: ${{ matrix.python-version }}
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 0867844f..a832daa6 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -116,7 +116,7 @@ def test_slice_int_rows_str_columns(constructor_eager: Any) -> None:
     compare_dicts(result, expected)
 
 
-def test_slice_slice_columns(constructor_eager: Any) -> None:
+def test_slice_slice_columns(constructor_eager: Any) -> None:  # noqa: PLR0915
     data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
     df = nw.from_native(constructor_eager(data), eager_only=True)
     result = df[[0, 1], "b":"c"]  # type: ignore[misc]
@@ -152,9 +152,29 @@ def test_slice_slice_columns(constructor_eager: Any) -> None:
     result = df[:2, [0, 2]]
     expected = {"a": [1, 2], "c": [7, 8]}
     compare_dicts(result, expected)
+    result = df[:2, ["a", "c"]]
+    expected = {"a": [1, 2], "c": [7, 8]}
+    compare_dicts(result, expected)
+    result = df[1:, [0, 2]]
+    expected = {"a": [2, 3], "c": [8, 9]}
+    compare_dicts(result, expected)
+    result = df[1:, ["a", "c"]]
+    expected = {"a": [2, 3], "c": [8, 9]}
+    compare_dicts(result, expected)
     result = df[["b", "c"]]
     expected = {"b": [4, 5, 6], "c": [7, 8, 9]}
     compare_dicts(result, expected)
+    result = df[:2]
+    expected = {"a": [1, 2], "b": [4, 5], "c": [7, 8], "d": [1, 4]}
+    compare_dicts(result, expected)
+    result = df[2:]
+    expected = {"a": [3], "b": [6], "c": [9], "d": [2]}
+    compare_dicts(result, expected)
+    # mypy says "Slice index must be an integer", but we do in fact support
+    # using string slices
+    result = df["a":"b"]  # type: ignore[misc]
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    compare_dicts(result, expected)
 
 
 def test_slice_invalid(constructor_eager: Any) -> None:
@@ -162,3 +182,14 @@ def test_slice_invalid(constructor_eager: Any) -> None:
     df = nw.from_native(constructor_eager(data), eager_only=True)
     with pytest.raises(TypeError, match="Hint:"):
         df[0, 0]
+
+
+def test_slice_edge_cases(constructor_eager: Any) -> None:
+    data = {"a": [1, 2, 3], "b": [4, 5, 6], "c": [7, 8, 9], "d": [1, 4, 2]}
+    df = nw.from_native(constructor_eager(data), eager_only=True)
+    assert df[[], :].shape == (0, 4)
+    assert df[:, []].shape == (0, 0)
+    assert df[[]].shape == (0, 4)
+    assert df[[], ["a"]].shape == (0, 1)
+    assert df[:, :].shape == (3, 4)
+    assert df[[], []].shape == (0, 0)
diff --git a/tests/series_only/slice_test.py b/tests/series_only/slice_test.py
index 48cf15bc..9ae19477 100644
--- a/tests/series_only/slice_test.py
+++ b/tests/series_only/slice_test.py
@@ -25,3 +25,9 @@ def test_slice(constructor_eager: Any) -> None:
     result = {"b": df[:2, 1]}
     expected = {"b": [4, 5]}
     compare_dicts(result, expected)
+    result = {"b": df[[0, 1], 1]}
+    expected = {"b": [4, 5]}
+    compare_dicts(result, expected)
+    result = {"b": df[[], 1]}
+    expected = {"b": []}
+    compare_dicts(result, expected)