Repository: narwhals-dev/narwhals
Pull request #445 (2024-07-09 00:40:50)

Refactor: Re enable and fix Ruff's TRY Checks
## What type of PR is this? (check all applicable)

- [X] 💾 Refactor
- [ ] ✨ Feature
- [ ] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Checklist

- [X] Code follows style guide (ruff)
- [ ] Tests added 
- [ ] Documented the changes

MarcoGorelli: yeah let's get the other one in first
ELC: Changed all instances to `msg`

I do agree with FBruzzesi and would avoid variable names that are too short, but this is not critical

Patch:
diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml
index 0192e7cb..74b6b44e 100644
--- a/.github/release-drafter.yml
+++ b/.github/release-drafter.yml
@@ -2,7 +2,6 @@ exclude-labels:
   - skip changelog
   - release
 name-template: 'Narwhals v$RESOLVED_VERSION'
-tag-template: 'v$RESOLVED_VERSION'
 
 change-template: '- $TITLE (#$NUMBER)' 
     
diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml
index e6104d9d..03d090b0 100644
--- a/.github/workflows/publish_to_pypi.yml
+++ b/.github/workflows/publish_to_pypi.yml
@@ -49,3 +49,43 @@ jobs:
     - name: Publish distribution 📦 to PyPI
       uses: pypa/gh-action-pypi-publish@release/v1
 
+  github-release:
+    name: >-
+      Sign the Python 🐍 distribution 📦 with Sigstore
+      and upload them to GitHub Release
+    needs:
+    - publish-to-pypi
+    runs-on: ubuntu-latest
+    permissions:
+      contents: write  # IMPORTANT: mandatory for making GitHub Releases
+      id-token: write  # IMPORTANT: mandatory for sigstore
+    steps:
+    - name: Download all the dists
+      uses: actions/download-artifact@v4
+      with:
+        name: python-package-distributions
+        path: dist/
+    - name: Sign the dists with Sigstore
+      uses: sigstore/gh-action-sigstore-python@v2.1.1
+      with:
+        inputs: >-
+          ./dist/*.tar.gz
+          ./dist/*.whl
+    - name: Create GitHub Release
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      run: >-
+        gh release create
+        '${{ github.ref_name }}'
+        --repo '${{ github.repository }}'
+        --notes ""
+    - name: Upload artifact signatures to GitHub Release
+      env:
+        GITHUB_TOKEN: ${{ github.token }}
+      # Upload to GitHub Release using the `gh` CLI.
+      # `dist/` contains the built packages, and the
+      # sigstore-produced signatures and certificates.
+      run: >-
+        gh release upload
+        '${{ github.ref_name }}' dist/**
+        --repo '${{ github.repository }}'
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/docs/installation.md b/docs/installation.md
index f3ee0814..ce0298f7 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -11,6 +11,6 @@ Then, if you start the Python REPL and see the following:
 ```python
 >>> import narwhals
 >>> narwhals.__version__
-'1.0.3'
+'1.0.5'
 ```
 then installation worked correctly!
diff --git a/docs/overhead.md b/docs/overhead.md
index 7edfb1f9..1477f6fa 100644
--- a/docs/overhead.md
+++ b/docs/overhead.md
@@ -10,7 +10,7 @@ vs running pandas via Narwhals:
 
 ![Comparison of pandas vs "pandas via Narwhals" timings on TPC-H queries showing neglibile overhead](https://github.com/narwhals-dev/narwhals/assets/33491632/71029c26-4121-43bb-90fb-5ac1c16ab8a2)
 
-[Here](https://www.kaggle.com/code/marcogorelli/narwhals-tpc-h-results-s-2-w-native)'s the code to
+[Here](https://www.kaggle.com/code/marcogorelli/narwhals-tpc-h-results-s-2)'s the code to
 reproduce the plot above, check the input
 sources for notebooks which run each individual query, along with
 the data sources.
diff --git a/mkdocs.yml b/mkdocs.yml
index 88b43a43..cc201760 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -45,6 +45,26 @@ theme:
     - content.code.annotate
     - navigation.footer
     - navigation.indexes
+  palette:
+    # Palette toggle for automatic mode
+    - media: "(prefers-color-scheme)"
+      toggle:
+        icon: material/brightness-auto
+        name: Switch to light mode
+
+    # Palette toggle for light mode
+    - media: "(prefers-color-scheme: light)"
+      scheme: default
+      toggle:
+        icon: material/brightness-7
+        name: Switch to dark mode
+
+    # Palette toggle for dark mode
+    - media: "(prefers-color-scheme: dark)"
+      scheme: slate
+      toggle:
+        icon: material/brightness-4
+        name: Switch to system preference
 plugins:
 - search
 - mkdocstrings:
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 8c3beb39..f0d116a8 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -43,7 +43,7 @@ from narwhals.utils import maybe_align_index
 from narwhals.utils import maybe_convert_dtypes
 from narwhals.utils import maybe_set_index
 
-__version__ = "1.0.3"
+__version__ = "1.0.5"
 
 __all__ = [
     "selectors",
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index efee201d..fa03b625 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -63,13 +63,30 @@ 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: tuple[Sequence[int], str | int]) -> ArrowSeries: ...  # type: ignore[overload-overlap]
+
+    @overload
+    def __getitem__(self, item: Sequence[int]) -> ArrowDataFrame: ...
+
     @overload
     def __getitem__(self, item: str) -> ArrowSeries: ...
 
     @overload
     def __getitem__(self, item: slice) -> ArrowDataFrame: ...
 
-    def __getitem__(self, item: str | slice) -> ArrowSeries | ArrowDataFrame:
+    def __getitem__(
+        self, item: str | slice | Sequence[int] | tuple[Sequence[int], str | int]
+    ) -> ArrowSeries | ArrowDataFrame:
         if isinstance(item, str):
             from narwhals._arrow.series import ArrowSeries
 
@@ -78,6 +95,16 @@ class ArrowDataFrame:
                 name=item,
                 backend_version=self._backend_version,
             )
+        elif isinstance(item, tuple) and len(item) == 2:
+            from narwhals._arrow.series import ArrowSeries
+
+            # PyArrow columns are always strings
+            col_name = item[1] if isinstance(item[1], str) else self.columns[item[1]]
+            return ArrowSeries(
+                self._native_dataframe[col_name].take(item[0]),
+                name=col_name,
+                backend_version=self._backend_version,
+            )
 
         elif isinstance(item, slice):
             if item.step is not None and item.step != 1:
@@ -186,6 +213,57 @@ class ArrowDataFrame:
     def to_pandas(self) -> Any:
         return self._native_dataframe.to_pandas()
 
+    def to_numpy(self) -> Any:
+        import numpy as np
+
+        return np.column_stack([col.to_numpy() for col in self._native_dataframe.columns])
+
+    def to_dict(self, *, as_series: bool) -> Any:
+        df = self._native_dataframe
+
+        names_and_values = zip(df.column_names, df.columns)
+        if as_series:
+            from narwhals._arrow.series import ArrowSeries
+
+            return {
+                name: ArrowSeries(col, name=name, backend_version=self._backend_version)
+                for name, col in names_and_values
+            }
+        else:
+            return {name: col.to_pylist() for name, col in names_and_values}
+
+    def with_row_index(self, name: str) -> Self:
+        pa = get_pyarrow()
+        df = self._native_dataframe
+
+        row_indices = pa.array(range(df.num_rows))
+        return self._from_native_dataframe(df.append_column(name, row_indices))
+
+    def null_count(self) -> Self:
+        pa = get_pyarrow()
+        df = self._native_dataframe
+        names_and_values = zip(df.column_names, df.columns)
+
+        return self._from_native_dataframe(
+            pa.table({name: [col.null_count] for name, col in names_and_values})
+        )
+
+    def head(self, n: int) -> Self:
+        df = self._native_dataframe
+        if n >= 0:
+            return self._from_native_dataframe(df.slice(0, n))
+        else:
+            num_rows = df.num_rows
+            return self._from_native_dataframe(df.slice(0, max(0, num_rows + n)))
+
+    def tail(self, n: int) -> Self:
+        df = self._native_dataframe
+        if n >= 0:
+            num_rows = df.num_rows
+            return self._from_native_dataframe(df.slice(max(0, num_rows - n)))
+        else:
+            return self._from_native_dataframe(df.slice(abs(n)))
+
     def lazy(self) -> Self:
         return self
 
diff --git a/narwhals/_arrow/expr.py b/narwhals/_arrow/expr.py
index 0f32c5c8..cd0332ef 100644
--- a/narwhals/_arrow/expr.py
+++ b/narwhals/_arrow/expr.py
@@ -17,7 +17,7 @@ if TYPE_CHECKING:
 
 
 class ArrowExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
@@ -122,6 +122,15 @@ class ArrowExpr:
             backend_version=self._backend_version,
         )
 
+    def null_count(self) -> Self:
+        return reuse_series_implementation(self, "null_count", returns_scalar=True)
+
+    def head(self, n: int) -> Self:
+        return reuse_series_implementation(self, "head", n)
+
+    def tail(self, n: int) -> Self:
+        return reuse_series_implementation(self, "tail", n)
+
     @property
     def dt(self) -> ArrowExprDateTimeNamespace:
         return ArrowExprDateTimeNamespace(self)
diff --git a/narwhals/_arrow/namespace.py b/narwhals/_arrow/namespace.py
index c7c94973..14f73381 100644
--- a/narwhals/_arrow/namespace.py
+++ b/narwhals/_arrow/namespace.py
@@ -38,7 +38,7 @@ class ArrowNamespace:
     Duration = dtypes.Duration
     Date = dtypes.Date
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[ArrowDataFrame], list[ArrowSeries]],
         *,
@@ -73,7 +73,7 @@ class ArrowNamespace:
     def _create_series_from_scalar(self, value: Any, series: ArrowSeries) -> ArrowSeries:
         from narwhals._arrow.series import ArrowSeries
 
-        if self._backend_version < (13,):  # pragma: no cover
+        if self._backend_version < (13,) and hasattr(value, "as_py"):  # pragma: no cover
             value = value.as_py()
         return ArrowSeries._from_iterable(
             [value],
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 286bb37d..1fffd249 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -144,6 +144,25 @@ class ArrowSeries:
         dtype = reverse_translate_dtype(dtype)
         return self._from_native_series(pc.cast(ser, dtype))
 
+    def null_count(self: Self) -> int:
+        return self._native_series.null_count  # type: ignore[no-any-return]
+
+    def head(self, n: int) -> Self:
+        ser = self._native_series
+        if n >= 0:
+            return self._from_native_series(ser.slice(0, n))
+        else:
+            num_rows = len(ser)
+            return self._from_native_series(ser.slice(0, max(0, num_rows + n)))
+
+    def tail(self, n: int) -> Self:
+        ser = self._native_series
+        if n >= 0:
+            num_rows = len(ser)
+            return self._from_native_series(ser.slice(max(0, num_rows - n)))
+        else:
+            return self._from_native_series(ser.slice(abs(n)))
+
     @property
     def shape(self) -> tuple[int]:
         return (len(self._native_series),)
diff --git a/narwhals/_arrow/utils.py b/narwhals/_arrow/utils.py
index a6a01fdf..a031c156 100644
--- a/narwhals/_arrow/utils.py
+++ b/narwhals/_arrow/utils.py
@@ -140,4 +140,5 @@ def validate_dataframe_comparand(other: Any) -> Any:
             msg = "not implemented yet"  # pragma: no cover
             raise NotImplementedError(msg)
         return other._native_series
-    raise AssertionError("Please report a bug")
+    msg = "Please report a bug"  # pragma: no cover
+    raise AssertionError(msg)
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 13315244..bbfcacea 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -81,7 +81,8 @@ class PandasLikeDataFrame:
                     raise ValueError(
                         msg,
                     )
-            raise AssertionError("Pls report bug")
+            msg = "Please report a bug"  # pragma: no cover
+            raise AssertionError(msg)
 
     def _from_native_dataframe(self, df: Any) -> Self:
         return self.__class__(
@@ -90,13 +91,30 @@ 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: tuple[Sequence[int], str | int]) -> PandasLikeSeries: ...  # type: ignore[overload-overlap]
+
+    @overload
+    def __getitem__(self, item: Sequence[int]) -> PandasLikeDataFrame: ...
+
     @overload
     def __getitem__(self, item: str) -> PandasLikeSeries: ...
 
     @overload
     def __getitem__(self, item: slice) -> PandasLikeDataFrame: ...
 
-    def __getitem__(self, item: str | slice) -> PandasLikeSeries | PandasLikeDataFrame:
+    def __getitem__(
+        self, item: str | slice | Sequence[int] | tuple[Sequence[int], str | int]
+    ) -> PandasLikeSeries | PandasLikeDataFrame:
         if isinstance(item, str):
             from narwhals._pandas_like.series import PandasLikeSeries
 
@@ -106,6 +124,23 @@ class PandasLikeDataFrame:
                 backend_version=self._backend_version,
             )
 
+        elif isinstance(item, tuple) and len(item) == 2:
+            from narwhals._pandas_like.series import PandasLikeSeries
+
+            if isinstance(item[1], str):
+                native_series = self._native_dataframe.loc[item]
+            elif isinstance(item[1], int):
+                native_series = self._native_dataframe.iloc[item]
+            else:  # pragma: no cover
+                msg = f"Expected str or int, got: {type(item[1])}"
+                raise TypeError(msg)
+
+            return PandasLikeSeries(
+                native_series,
+                implementation=self._implementation,
+                backend_version=self._backend_version,
+            )
+
         elif isinstance(item, (slice, Sequence)) or (
             (np := get_numpy()) is not None
             and isinstance(item, np.ndarray)
@@ -296,8 +331,8 @@ class PandasLikeDataFrame:
         other: Self,
         *,
         how: Literal["left", "inner", "outer", "cross", "anti", "semi"] = "inner",
-        left_on: str | list[str] | None = None,
-        right_on: str | list[str] | None = None,
+        left_on: str | list[str] | None,
+        right_on: str | list[str] | None,
     ) -> Self:
         if isinstance(left_on, str):
             left_on = [left_on]
@@ -356,7 +391,6 @@ class PandasLikeDataFrame:
                 )
                 .loc[lambda t: t[indicator_token] == "left_only"]
                 .drop(columns=[indicator_token])
-                .reset_index(drop=True)
             )
 
         if how == "semi":
@@ -373,8 +407,25 @@ class PandasLikeDataFrame:
                     how="inner",
                     left_on=left_on,
                     right_on=left_on,
-                ).reset_index(drop=True)
+                )
+            )
+
+        if how == "left":
+            other_native = other._native_dataframe
+            result_native = self._native_dataframe.merge(
+                other_native,
+                how="left",
+                left_on=left_on,
+                right_on=right_on,
+                suffixes=("", "_right"),
             )
+            extra = []
+            for left_key, right_key in zip(left_on, right_on):  # type: ignore[arg-type]
+                if right_key != left_key and right_key not in self.columns:
+                    extra.append(right_key)
+                elif right_key != left_key:
+                    extra.append(f"{right_key}_right")
+            return self._from_native_dataframe(result_native.drop(columns=extra))
 
         return self._from_native_dataframe(
             self._native_dataframe.merge(
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index d507730f..30719f26 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -18,7 +18,7 @@ if TYPE_CHECKING:
 
 
 class PandasLikeExpr:
-    def __init__(  # noqa: PLR0913
+    def __init__(
         self,
         call: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 864268e7..f4e3ef95 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -91,7 +91,7 @@ class PandasLikeGroupBy:
         )
 
 
-def agg_pandas(  # noqa: PLR0913
+def agg_pandas(
     grouped: Any,
     exprs: list[PandasLikeExpr],
     keys: list[str],
@@ -146,9 +146,8 @@ def agg_pandas(  # noqa: PLR0913
         try:
             result_simple = grouped.agg(aggs)
         except AttributeError as exc:
-            raise RuntimeError(
-                "Failed to aggregated - does your aggregation function return a scalar?"
-            ) from exc
+            msg = "Failed to aggregated - does your aggregation function return a scalar?"
+            raise RuntimeError(msg) from exc
         result_simple.columns = [f"{a}_{b}" for a, b in result_simple.columns]
         result_simple = result_simple.rename(columns=name_mapping).reset_index()
         return from_dataframe(result_simple.loc[:, output_names])
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 6f54423f..da237aa0 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -56,7 +56,7 @@ class PandasLikeNamespace:
         self._implementation = implementation
         self._backend_version = backend_version
 
-    def _create_expr_from_callable(  # noqa: PLR0913
+    def _create_expr_from_callable(
         self,
         func: Callable[[PandasLikeDataFrame], list[PandasLikeSeries]],
         *,
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index b14d4c86..7b8cf0d7 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -131,7 +131,7 @@ class PandasLikeSeries:
         )
 
     @classmethod
-    def _from_iterable(  # noqa: PLR0913
+    def _from_iterable(
         cls: type[Self],
         data: Iterable[Any],
         name: str,
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index dd77d2f5..b9f6050f 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -87,7 +87,8 @@ def validate_dataframe_comparand(index: Any, other: Any) -> Any:
                 backend_version=other._backend_version,
             )
         return other._native_series
-    raise AssertionError("Please report a bug")
+    msg = "Please report a bug"  # pragma: no cover
+    raise AssertionError(msg)
 
 
 def create_native_series(
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index d0cadc80..d5cec6a0 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -174,11 +174,11 @@ class BaseFrame(Generic[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
-        _supported_joins = ("inner", "cross", "anti", "semi")
+        _supported_joins = ("inner", "left", "cross", "anti", "semi")
 
         if how not in _supported_joins:
             msg = f"Only the following join stragies are supported: {_supported_joins}; found '{how}'."
@@ -407,8 +407,59 @@ 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: tuple[Sequence[int], str | int]) -> Series: ...  # type: ignore[overload-overlap]
+
     @overload
-    def __getitem__(self, item: Sequence[int]) -> Series: ...
+    def __getitem__(self, item: Sequence[int]) -> Self: ...
 
     @overload
     def __getitem__(self, item: str) -> Series: ...
@@ -416,8 +467,55 @@ class DataFrame(BaseFrame[FrameT]):
     @overload
     def __getitem__(self, item: slice) -> Self: ...
 
-    def __getitem__(self, item: str | slice | Sequence[int]) -> Series | Self:
-        if isinstance(item, str):
+    def __getitem__(
+        self, item: str | slice | Sequence[int] | tuple[Sequence[int], str | 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.
+                - tuple of Sequence of integers and str or int: slice rows and extract column at the same time.
+                  If the second element of the tuple is an integer, it is interpreted as the column index. Otherwise,
+                  it is interpreted as the column name.
+        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) or (isinstance(item, tuple) and len(item) == 2):
             from narwhals.series import Series
 
             return Series(
@@ -1478,7 +1576,7 @@ class DataFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
@@ -1857,7 +1955,8 @@ class LazyFrame(BaseFrame[FrameT]):
         )
 
     def __getitem__(self, item: str | slice) -> Series | Self:
-        raise TypeError("Slicing is not supported on LazyFrame")
+        msg = "Slicing is not supported on LazyFrame"
+        raise TypeError(msg)
 
     def collect(self) -> DataFrame[Any]:
         r"""
@@ -2904,7 +3003,7 @@ class LazyFrame(BaseFrame[FrameT]):
         self,
         other: Self,
         *,
-        how: Literal["inner", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
     ) -> Self:
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 95346dbf..7703952a 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -3357,10 +3357,11 @@ def lit(value: Any, dtype: DType | None = None) -> Expr:
 
     """
     if (np := get_numpy()) is not None and isinstance(value, np.ndarray):
-        raise ValueError(
+        msg = (
             "numpy arrays are not supported as literal values. "
             "Consider using `with_columns` to create a new column from the array."
         )
+        raise ValueError(msg)
 
     if isinstance(value, (list, tuple)):
         msg = f"Nested datatypes are not supported yet. Got {value}"
diff --git a/narwhals/functions.py b/narwhals/functions.py
index 172fe3b6..c7e2b534 100644
--- a/narwhals/functions.py
+++ b/narwhals/functions.py
@@ -28,7 +28,8 @@ def concat(
             "Only horizontal and vertical concatenations are supported"
         )
     if not items:
-        raise ValueError("No items to concatenate")
+        msg = "No items to concatenate"
+        raise ValueError(msg)
     items = list(items)
     validate_same_library(items)
     validate_laziness(items)
diff --git a/narwhals/stable/v1.py b/narwhals/stable/v1.py
index 86602a1d..e27a4948 100644
--- a/narwhals/stable/v1.py
+++ b/narwhals/stable/v1.py
@@ -67,7 +67,10 @@ class DataFrame(NwDataFrame[IntoDataFrameT]):
     """
 
     @overload
-    def __getitem__(self, item: Sequence[int]) -> Series: ...
+    def __getitem__(self, item: tuple[Sequence[int], str | int]) -> Series: ...  # type: ignore[overload-overlap]
+
+    @overload
+    def __getitem__(self, item: Sequence[int]) -> Self: ...
 
     @overload
     def __getitem__(self, item: str) -> Series: ...
diff --git a/narwhals/translate.py b/narwhals/translate.py
index a7285700..8b243d33 100644
--- a/narwhals/translate.py
+++ b/narwhals/translate.py
@@ -265,7 +265,8 @@ def from_native(  # noqa: PLR0915
 
     if (pl := get_polars()) is not None and isinstance(native_dataframe, pl.DataFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with polars.DataFrame")
+            msg = "Cannot only use `series_only` with polars.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             native_dataframe,
             is_polars=True,
@@ -273,9 +274,11 @@ def from_native(  # noqa: PLR0915
         )
     elif (pl := get_polars()) is not None and isinstance(native_dataframe, pl.LazyFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with polars.LazyFrame")
+            msg = "Cannot only use `series_only` with polars.LazyFrame"
+            raise TypeError(msg)
         if eager_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `eager_only` with polars.LazyFrame")
+            msg = "Cannot only use `eager_only` with polars.LazyFrame"
+            raise TypeError(msg)
         return LazyFrame(
             native_dataframe,
             is_polars=True,
@@ -283,7 +286,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pd := get_pandas()) is not None and isinstance(native_dataframe, pd.DataFrame):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with dataframe")
+            msg = "Cannot only use `series_only` with dataframe"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -297,7 +301,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, mpd.DataFrame
     ):  # pragma: no cover
         if series_only:
-            raise TypeError("Cannot only use `series_only` with modin.DataFrame")
+            msg = "Cannot only use `series_only` with modin.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -311,7 +316,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, cudf.DataFrame
     ):
         if series_only:
-            raise TypeError("Cannot only use `series_only` with modin.DataFrame")
+            msg = "Cannot only use `series_only` with modin.DataFrame"
+            raise TypeError(msg)
         return DataFrame(
             PandasLikeDataFrame(
                 native_dataframe,
@@ -323,7 +329,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pa := get_pyarrow()) is not None and isinstance(native_dataframe, pa.Table):
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with arrow table")
+            msg = "Cannot only use `series_only` with arrow table"
+            raise TypeError(msg)
         return DataFrame(
             ArrowDataFrame(
                 native_dataframe, backend_version=parse_version(pa.__version__)
@@ -333,7 +340,8 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_dataframe__"):  # pragma: no cover
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with dataframe")
+            msg = "Cannot only use `series_only` with dataframe"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return DataFrame(
             native_dataframe.__narwhals_dataframe__(),
@@ -342,9 +350,11 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_lazyframe__"):  # pragma: no cover
         if series_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `series_only` with lazyframe")
+            msg = "Cannot only use `series_only` with lazyframe"
+            raise TypeError(msg)
         if eager_only:  # pragma: no cover (todo)
-            raise TypeError("Cannot only use `eager_only` with lazyframe")
+            msg = "Cannot only use `eager_only` with lazyframe"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return LazyFrame(
             native_dataframe.__narwhals_lazyframe__(),
@@ -353,7 +363,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pl := get_polars()) is not None and isinstance(native_dataframe, pl.Series):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             native_dataframe,
             is_polars=True,
@@ -361,7 +372,8 @@ def from_native(  # noqa: PLR0915
         )
     elif (pd := get_pandas()) is not None and isinstance(native_dataframe, pd.Series):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -375,7 +387,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, mpd.Series
     ):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -389,7 +402,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, cudf.Series
     ):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             PandasLikeSeries(
                 native_dataframe,
@@ -403,7 +417,8 @@ def from_native(  # noqa: PLR0915
         native_dataframe, pa.ChunkedArray
     ):
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         return Series(
             ArrowSeries(
                 native_dataframe, backend_version=parse_version(pa.__version__), name=""
@@ -413,7 +428,8 @@ def from_native(  # noqa: PLR0915
         )
     elif hasattr(native_dataframe, "__narwhals_series__"):  # pragma: no cover
         if not allow_series:  # pragma: no cover (todo)
-            raise TypeError("Please set `allow_series=True`")
+            msg = "Please set `allow_series=True`"
+            raise TypeError(msg)
         # placeholder (0,) version here, as we wouldn't use it in this case anyway.
         return Series(
             native_dataframe.__narwhals_series__(), backend_version=(0,), is_polars=False
diff --git a/narwhals/typing.py b/narwhals/typing.py
index 08c40d6c..52a30edc 100644
--- a/narwhals/typing.py
+++ b/narwhals/typing.py
@@ -29,14 +29,17 @@ if TYPE_CHECKING:
         def join(self, *args: Any, **kwargs: Any) -> Any: ...
 
 
-# Anything which can be converted to an expression.
 IntoExpr: TypeAlias = Union["Expr", str, "Series"]
-# Anything which can be converted to a Narwhals DataFrame.
+"""Anything which can be converted to an expression."""
+
 IntoDataFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]"]
-# Anything which can be converted to a Narwhals DataFrame or LazyFrame.
+"""Anything which can be converted to a Narwhals DataFrame."""
+
 IntoFrame: TypeAlias = Union["NativeFrame", "DataFrame[Any]", "LazyFrame[Any]"]
-# DataFrame or LazyFrame
+"""Anything which can be converted to a Narwhals DataFrame or LazyFrame."""
+
 Frame: TypeAlias = Union["DataFrame[Any]", "LazyFrame[Any]"]
+"""DataFrame or LazyFrame"""
 
 # TypeVars for some of the above
 IntoFrameT = TypeVar("IntoFrameT", bound="IntoFrame")
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 0f4baec3..7f65d8bc 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -138,7 +138,8 @@ def maybe_align_index(lhs: T, rhs: Series | BaseFrame[Any]) -> T:
 
     def _validate_index(index: Any) -> None:
         if not index.is_unique:
-            raise ValueError("given index doesn't have a unique index")
+            msg = "given index doesn't have a unique index"
+            raise ValueError(msg)
 
     lhs_any = cast(Any, lhs)
     rhs_any = cast(Any, rhs)
diff --git a/pyproject.toml b/pyproject.toml
index 7dd4fe41..29c9d2b7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
 
 [project]
 name = "narwhals"
-version = "1.0.3"
+version = "1.0.5"
 authors = [
   { name="Marco Gorelli", email="33491632+MarcoGorelli@users.noreply.github.com" },
 ]
@@ -54,9 +54,11 @@ lint.ignore = [
   'E501',
   'EM101',
   'FIX',
+  'ISC001',
   'PD901',  # This is a auxiliary library so dataframe variables have no concrete business meaning
   'PLR0911',
   'PLR0912',
+  'PLR0913',
   'PLR2004',
   'PTH',
   'RET505',
diff --git a/tpch/notebooks/q17/execute.ipynb b/tpch/notebooks/q17/execute.ipynb
new file mode 100644
index 00000000..958c7f5b
--- /dev/null
+++ b/tpch/notebooks/q17/execute.ipynb
@@ -0,0 +1,357 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 33.390992,
+     "end_time": "2024-03-22T17:24:15.601719",
+     "exception": false,
+     "start_time": "2024-03-22T17:23:42.210727",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "!pip uninstall apache-beam -y && pip install -U pandas polars pyarrow narwhals "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 0.907754,
+     "end_time": "2024-03-22T17:24:39.053873",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:38.146119",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import polars as pl\n",
+    "\n",
+    "pd.options.mode.copy_on_write = True\n",
+    "pd.options.future.infer_string = True"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 0.021725,
+     "end_time": "2024-03-22T17:24:39.080999",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:39.059274",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "from typing import Any\n",
+    "import narwhals as nw\n",
+    "\n",
+    "def q17(\n",
+    "    lineitem_ds_raw: Any,\n",
+    "    part_ds_raw: Any\n",
+    ") -> Any:\n",
+    "\n",
+    "    lineitem_ds = nw.from_native(lineitem_ds_raw)\n",
+    "    part_ds = nw.from_native(part_ds_raw)\n",
+    "    \n",
+    "    var1 = \"Brand#23\"\n",
+    "    var2 = \"MED BOX\"\n",
+    "    \n",
+    "    query1 = (\n",
+    "        part_ds.filter(nw.col(\"p_brand\") == var1)\n",
+    "        .filter(nw.col(\"p_container\") == var2)\n",
+    "        .join(lineitem_ds, how=\"left\", left_on=\"p_partkey\", right_on=\"l_partkey\")\n",
+    "    )\n",
+    "    \n",
+    "    final_query = (\n",
+    "        query1.group_by(\"p_partkey\")\n",
+    "        .agg((0.2 * nw.col(\"l_quantity\").mean()).alias(\"avg_quantity\"))\n",
+    "        .select(nw.col(\"p_partkey\").alias(\"key\"), nw.col(\"avg_quantity\"))\n",
+    "        .join(query1, left_on=\"key\", right_on=\"p_partkey\")\n",
+    "        .filter(nw.col(\"l_quantity\") < nw.col(\"avg_quantity\"))\n",
+    "        .select((nw.col(\"l_extendedprice\").sum() / 7.0).round(2).alias(\"avg_yearly\"))\n",
+    "    )\n",
+    "\n",
+    "\n",
+    "    return nw.to_native(final_query)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 0.013325,
+     "end_time": "2024-03-22T17:24:39.099766",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:39.086441",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "dir_ = \"/kaggle/input/tpc-h-data-parquet-s-2/\"\n",
+    "lineitem = dir_ + 'lineitem.parquet'\n",
+    "part = dir_ + 'part.parquet'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 0.014284,
+     "end_time": "2024-03-22T17:24:39.119737",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:39.105453",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "IO_FUNCS = {\n",
+    "    'pandas': lambda x: pd.read_parquet(x, engine='pyarrow'),\n",
+    "    'pandas[pyarrow]': lambda x: pd.read_parquet(x, engine='pyarrow', dtype_backend='pyarrow'),\n",
+    "    'polars[eager]': lambda x: pl.read_parquet(x),\n",
+    "    'polars[lazy]': lambda x: pl.scan_parquet(x),\n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "results = {}"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "papermill": {
+     "duration": 0.005113,
+     "end_time": "2024-03-22T17:24:39.130472",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:39.125359",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "source": [
+    "## pandas via Narwhals"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 196.786925,
+     "end_time": "2024-03-22T17:27:55.922832",
+     "exception": false,
+     "start_time": "2024-03-22T17:24:39.135907",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "tool = 'pandas'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q17(fn(lineitem), fn(part))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "papermill": {
+     "duration": 0.005184,
+     "end_time": "2024-03-22T17:27:55.933407",
+     "exception": false,
+     "start_time": "2024-03-22T17:27:55.928223",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "source": [
+    "## pandas, pyarrow dtypes, via Narwhals"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 158.748353,
+     "end_time": "2024-03-22T17:30:34.688289",
+     "exception": false,
+     "start_time": "2024-03-22T17:27:55.939936",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "tool = 'pandas[pyarrow]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q17(fn(lineitem), fn(part))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "papermill": {
+     "duration": 0.005773,
+     "end_time": "2024-03-22T17:30:34.7003",
+     "exception": false,
+     "start_time": "2024-03-22T17:30:34.694527",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "source": [
+    "## Polars read_parquet"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 37.821116,
+     "end_time": "2024-03-22T17:31:12.527466",
+     "exception": false,
+     "start_time": "2024-03-22T17:30:34.70635",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "tool = 'polars[eager]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q17(fn(lineitem), fn(part))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {
+    "papermill": {
+     "duration": 0.005515,
+     "end_time": "2024-03-22T17:31:12.539068",
+     "exception": false,
+     "start_time": "2024-03-22T17:31:12.533553",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "source": [
+    "## Polars scan_parquet"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "papermill": {
+     "duration": 4.800698,
+     "end_time": "2024-03-22T17:31:17.346813",
+     "exception": false,
+     "start_time": "2024-03-22T17:31:12.546115",
+     "status": "completed"
+    },
+    "tags": []
+   },
+   "outputs": [],
+   "source": [
+    "tool = 'polars[lazy]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q17(fn(lineitem), fn(part)).collect()\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Save"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import json\n",
+    "with open('results.json', 'w') as fd:\n",
+    "    json.dump(results, fd)\n"
+   ]
+  }
+ ],
+ "metadata": {
+  "kaggle": {
+   "accelerator": "none",
+   "dataSources": [
+    {
+     "sourceId": 167796934,
+     "sourceType": "kernelVersion"
+    }
+   ],
+   "dockerImageVersionId": 30673,
+   "isGpuEnabled": false,
+   "isInternetEnabled": true,
+   "language": "python",
+   "sourceType": "notebook"
+  },
+  "kernelspec": {
+   "display_name": "Python 3",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.10.13"
+  },
+  "papermill": {
+   "default_parameters": {},
+   "duration": 458.423327,
+   "end_time": "2024-03-22T17:31:18.077306",
+   "environment_variables": {},
+   "exception": null,
+   "input_path": "__notebook__.ipynb",
+   "output_path": "__notebook__.ipynb",
+   "parameters": {},
+   "start_time": "2024-03-22T17:23:39.653979",
+   "version": "2.5.0"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/tpch/notebooks/q17/kernel-metadata.json b/tpch/notebooks/q17/kernel-metadata.json
new file mode 100644
index 00000000..0fd73368
--- /dev/null
+++ b/tpch/notebooks/q17/kernel-metadata.json
@@ -0,0 +1,15 @@
+{
+  "id": "marcogorelli/narwhals-tpch-q17-s2",
+  "title": "Narwhals TPCH Q17 S2",
+  "code_file": "execute.ipynb",
+  "language": "python",
+  "kernel_type": "notebook",
+  "is_private": "false",
+  "enable_gpu": "false",
+  "enable_tpu": "false",
+  "enable_internet": "true",
+  "dataset_sources": [],
+  "competition_sources": [],
+  "kernel_sources": ["marcogorelli/tpc-h-data-parquet-s-2"],
+  "model_sources": []
+}
\ No newline at end of file
diff --git a/tpch/notebooks/q18/execute.ipynb b/tpch/notebooks/q18/execute.ipynb
new file mode 100644
index 00000000..21557c95
--- /dev/null
+++ b/tpch/notebooks/q18/execute.ipynb
@@ -0,0 +1,211 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "!pip uninstall apache-beam -y && pip install -U pandas polars pyarrow narwhals "
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import pandas as pd\n",
+    "import polars as pl\n",
+    "\n",
+    "pd.options.mode.copy_on_write = True\n",
+    "pd.options.future.infer_string = True"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "from typing import Any\n",
+    "import narwhals as nw\n",
+    "\n",
+    "def q18(\n",
+    "    customer_ds_raw: Any,\n",
+    "    lineitem_ds_raw: Any,\n",
+    "    orders_ds_raw: Any\n",
+    ") -> Any:\n",
+    "\n",
+    "    customer_ds = nw.from_native(customer_ds_raw)\n",
+    "    lineitem_ds = nw.from_native(lineitem_ds_raw)\n",
+    "    orders_ds = nw.from_native(orders_ds_raw)\n",
+    "    \n",
+    "    var1 = 300\n",
+    "\n",
+    "    query1 = (\n",
+    "        lineitem_ds.group_by(\"l_orderkey\")\n",
+    "        .agg(nw.col(\"l_quantity\").sum().alias(\"sum_quantity\"))\n",
+    "        .filter(nw.col(\"sum_quantity\") > var1)\n",
+    "    )\n",
+    "\n",
+    "    q_final = (\n",
+    "        orders_ds.join(query1, left_on=\"o_orderkey\", right_on=\"l_orderkey\", how=\"semi\")\n",
+    "        .join(lineitem_ds, left_on=\"o_orderkey\", right_on=\"l_orderkey\")\n",
+    "        .join(customer_ds, left_on=\"o_custkey\", right_on=\"c_custkey\")\n",
+    "        .group_by(\"c_name\", \"o_custkey\", \"o_orderkey\", \"o_orderdate\", \"o_totalprice\")\n",
+    "        .agg(nw.col(\"l_quantity\").sum().alias(\"col6\"))\n",
+    "        .select(\n",
+    "            nw.col(\"c_name\"),\n",
+    "            nw.col(\"o_custkey\").alias(\"c_custkey\"),\n",
+    "            nw.col(\"o_orderkey\"),\n",
+    "            nw.col(\"o_orderdate\").alias(\"o_orderdat\"),\n",
+    "            nw.col(\"o_totalprice\"),\n",
+    "            nw.col(\"col6\"),\n",
+    "        )\n",
+    "        .sort(by=[\"o_totalprice\", \"o_orderdat\"], descending=[True, False])\n",
+    "        .head(100)\n",
+    "    )\n",
+    "\n",
+    "\n",
+    "    return nw.to_native(q_final)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "dir_ = \"/kaggle/input/tpc-h-data-parquet-s-2/\"\n",
+    "customer = dir_ + 'customer.parquet'\n",
+    "lineitem = dir_ + 'lineitem.parquet'\n",
+    "orders = dir_ + 'orders.parquet'"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "IO_FUNCS = {\n",
+    "    'pandas': lambda x: pd.read_parquet(x, engine='pyarrow'),\n",
+    "    'pandas[pyarrow]': lambda x: pd.read_parquet(x, engine='pyarrow', dtype_backend='pyarrow'),\n",
+    "    'polars[eager]': lambda x: pl.read_parquet(x),\n",
+    "    'polars[lazy]': lambda x: pl.scan_parquet(x),\n",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "results = {}"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## pandas via Narwhals"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tool = 'pandas'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q19(fn(lineitem), fn(part))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## pandas, pyarrow dtypes, via Narwhals"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tool = 'pandas[pyarrow]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q18(fn(customer), fn(lineitem), fn(orders))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Polars read_parquet"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tool = 'polars[eager]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q18(fn(customer), fn(lineitem), fn(orders))\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Polars scan_parquet"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "tool = 'polars[lazy]'\n",
+    "fn = IO_FUNCS[tool]\n",
+    "timings = %timeit -o q18(fn(customer), fn(lineitem), fn(orders)).collect()\n",
+    "results[tool] = timings.all_runs"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "metadata": {},
+   "source": [
+    "## Save"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import json\n",
+    "with open('results.json', 'w') as fd:\n",
+    "    json.dump(results, fd)\n"
+   ]
+  }
+ ],
+ "metadata": {
+  "language_info": {
+   "name": "python"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/tpch/notebooks/q18/kernel-metadata.json b/tpch/notebooks/q18/kernel-metadata.json
new file mode 100644
index 00000000..e1c11e53
--- /dev/null
+++ b/tpch/notebooks/q18/kernel-metadata.json
@@ -0,0 +1,15 @@
+{
+  "id": "marcogorelli/narwhals-tpch-q18-s2",
+  "title": "Narwhals TPCH Q18 S2",
+  "code_file": "execute.ipynb",
+  "language": "python",
+  "kernel_type": "notebook",
+  "is_private": "false",
+  "enable_gpu": "false",
+  "enable_tpu": "false",
+  "enable_internet": "true",
+  "dataset_sources": [],
+  "competition_sources": [],
+  "kernel_sources": ["marcogorelli/tpc-h-data-parquet-s-2"],
+  "model_sources": []
+}
\ No newline at end of file
diff --git a/utils/bump_version.py b/utils/bump_version.py
index 3a0208d3..e1bbf8d2 100644
--- a/utils/bump_version.py
+++ b/utils/bump_version.py
@@ -4,6 +4,9 @@ import re
 import subprocess
 import sys
 
+subprocess.run(["git", "fetch", "upstream"])
+subprocess.run(["git", "reset", "--hard", "upstream/main"])
+
 how = sys.argv[1]
 
 with open("pyproject.toml", encoding="utf-8") as f:
@@ -39,5 +42,5 @@ with open("docs/installation.md", "w", encoding="utf-8") as f:
     f.write(content)
 
 subprocess.run(["git", "commit", "-a", "-m", f"Bump version to {version}"])
-subprocess.run(["git", "tag", "-a", version, "-m", version])
+subprocess.run(["git", "tag", "-a", f"v{version}", "-m", f"v{version}"])
 subprocess.run(["git", "push", "upstream", "HEAD", "--follow-tags"])
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
diff --git a/utils/check_backend_completeness.py b/utils/check_backend_completeness.py
index 924115fb..f1d0b184 100644
--- a/utils/check_backend_completeness.py
+++ b/utils/check_backend_completeness.py
@@ -14,27 +14,20 @@ from narwhals._arrow.dataframe import ArrowDataFrame
 MISSING = [
     "DataFrame.filter",
     "DataFrame.group_by",
-    "DataFrame.head",
     "DataFrame.is_duplicated",
     "DataFrame.is_empty",
     "DataFrame.is_unique",
     "DataFrame.item",
     "DataFrame.iter_rows",
     "DataFrame.join",
-    "DataFrame.null_count",
     "DataFrame.pipe",
     "DataFrame.rename",
-    "DataFrame.tail",
-    "DataFrame.to_dict",
-    "DataFrame.to_numpy",
     "DataFrame.unique",
-    "DataFrame.with_row_index",
     "DataFrame.write_parquet",
     "Series.drop_nulls",
     "Series.fill_null",
     "Series.filter",
     "Series.from_iterable",
-    "Series.head",
     "Series.is_between",
     "Series.is_duplicated",
     "Series.is_first_distinct",
@@ -48,14 +41,12 @@ MISSING = [
     "Series.max",
     "Series.min",
     "Series.n_unique",
-    "Series.null_count",
     "Series.quantile",
     "Series.round",
     "Series.sample",
     "Series.shift",
     "Series.sort",
     "Series.sum",
-    "Series.tail",
     "Series.to_frame",
     "Series.to_pandas",
     "Series.unique",


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index c7d2a78b..1453a054 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -36,7 +36,7 @@ def pandas_constructor(obj: Any) -> IntoDataFrame:
 
 
 def pandas_nullable_constructor(obj: Any) -> IntoDataFrame:
-    return pd.DataFrame(obj).convert_dtypes()  # type: ignore[no-any-return]
+    return pd.DataFrame(obj).convert_dtypes(dtype_backend="numpy_nullable")  # type: ignore[no-any-return]
 
 
 def pandas_pyarrow_constructor(obj: Any) -> IntoDataFrame:
diff --git a/tests/expr/null_count_test.py b/tests/expr/null_count_test.py
index 5461db86..e5a02e26 100644
--- a/tests/expr/null_count_test.py
+++ b/tests/expr/null_count_test.py
@@ -9,8 +9,8 @@ data = {
 }
 
 
-def test_null_count(constructor: Any) -> None:
-    df = nw.from_native(constructor(data), eager_only=True)
+def test_null_count(constructor_with_pyarrow: Any) -> None:
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
     result = df.select(nw.all().null_count())
     expected = {
         "a": [2],
diff --git a/tests/expr/unary_test.py b/tests/expr/unary_test.py
new file mode 100644
index 00000000..c1308443
--- /dev/null
+++ b/tests/expr/unary_test.py
@@ -0,0 +1,22 @@
+from typing import Any
+
+import narwhals as nw
+from tests.utils import compare_dicts
+
+
+def test_unary(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    result = (
+        nw.from_native(constructor_with_lazy(data))
+        .with_columns(
+            a_mean=nw.col("a").mean(),
+            a_sum=nw.col("a").sum(),
+            b_nunique=nw.col("b").n_unique(),
+            z_min=nw.col("z").min(),
+            z_max=nw.col("z").max(),
+        )
+        .select(nw.col("a_mean", "a_sum", "b_nunique", "z_min", "z_max").unique())
+    )
+    result_native = nw.to_native(result)
+    expected = {"a_mean": [2], "a_sum": [6], "b_nunique": [2], "z_min": [7], "z_max": [9]}
+    compare_dicts(result_native, expected)
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]
diff --git a/tests/frame/head_test.py b/tests/frame/head_test.py
new file mode 100644
index 00000000..9b87e579
--- /dev/null
+++ b/tests/frame/head_test.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_head(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
+
+    df_raw = constructor_with_pyarrow(data)
+    df = nw.from_native(df_raw).lazy()
+
+    result = df.head(2)
+    compare_dicts(result, expected)
+
+    result = df.collect().head(2)  # type: ignore[assignment]
+    compare_dicts(result, expected)
+
+    result = df.collect().head(-1)  # type: ignore[assignment]
+    compare_dicts(result, expected)
+
+    result = df.collect().select(nw.col("a").head(2))  # type: ignore[assignment]
+    compare_dicts(result, {"a": expected["a"]})
diff --git a/tests/frame/is_duplicated_test.py b/tests/frame/is_duplicated_test.py
new file mode 100644
index 00000000..041d6841
--- /dev/null
+++ b/tests/frame/is_duplicated_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_is_duplicated(constructor: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = nw.concat([df, df.head(1)]).is_duplicated()
+    expected = np.array([True, False, False, True])
+    assert (result.to_numpy() == expected).all()
diff --git a/tests/frame/is_empty_test.py b/tests/frame/is_empty_test.py
new file mode 100644
index 00000000..06defe73
--- /dev/null
+++ b/tests/frame/is_empty_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals.stable.v1 as nw
+
+
+@pytest.mark.parametrize(("threshold", "expected"), [(0, False), (10, True)])
+def test_is_empty(constructor: Any, threshold: Any, expected: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = df.filter(nw.col("a") > threshold).is_empty()
+    assert result == expected
diff --git a/tests/frame/is_unique_test.py b/tests/frame/is_unique_test.py
new file mode 100644
index 00000000..2f41b672
--- /dev/null
+++ b/tests/frame/is_unique_test.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_is_unique(constructor: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = nw.concat([df, df.head(1)]).is_unique()
+    expected = np.array([False, True, True, False])
+    assert (result.to_numpy() == expected).all()
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index a8339277..de91f5b2 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -96,7 +96,7 @@ def test_semi_join(
     compare_dicts(result, expected)
 
 
-@pytest.mark.parametrize("how", ["left", "right", "full"])
+@pytest.mark.parametrize("how", ["right", "full"])
 def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
     df = nw.from_native(constructor_with_lazy(data))
@@ -104,7 +104,46 @@ def test_join_not_implemented(constructor_with_lazy: Any, how: str) -> None:
     with pytest.raises(
         NotImplementedError,
         match=re.escape(
-            f"Only the following join stragies are supported: ('inner', 'cross', 'anti', 'semi'); found '{how}'."
+            f"Only the following join stragies are supported: ('inner', 'left', 'cross', 'anti', 'semi'); found '{how}'."
         ),
     ):
         df.join(df, left_on="a", right_on="a", how=how)  # type: ignore[arg-type]
+
+
+@pytest.mark.filterwarnings("ignore:the default coalesce behavior")
+def test_left_join(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6], "a_right": [1, 2, 3]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_multiple_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on=["a", "b"], right_on=["a", "c"], how="left")
+    expected = {"a": [1, 2, 3], "b": [4, 5, 6]}
+    compare_dicts(result, expected)
+
+
+@pytest.mark.filterwarnings("ignore: the defaultcoalesce behavior")
+def test_left_join_overlapping_column(constructor: Any) -> None:
+    data_left = {"a": [1, 2, 3], "b": [4, 5, 6], "d": [1, 4, 2]}
+    data_right = {"a": [1, 2, 3], "c": [4, 5, 6], "d": [1, 4, 2]}
+    df_left = nw.from_native(constructor(data_left), eager_only=True)
+    df_right = nw.from_native(constructor(data_right), eager_only=True)
+    result = df_left.join(df_right, left_on="b", right_on="c", how="left")
+    expected = {
+        "a": [1, 2, 3],
+        "b": [4, 5, 6],
+        "d": [1, 4, 2],
+        "a_right": [1, 2, 3],
+        "d_right": [1, 4, 2],
+    }
+    compare_dicts(result, expected)
diff --git a/tests/frame/lit_test.py b/tests/frame/lit_test.py
new file mode 100644
index 00000000..1a79b344
--- /dev/null
+++ b/tests/frame/lit_test.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+from typing import Any
+
+import numpy as np
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+if TYPE_CHECKING:
+    from narwhals.dtypes import DType
+
+
+@pytest.mark.parametrize(
+    ("dtype", "expected_lit"),
+    [(None, [2, 2, 2]), (nw.String, ["2", "2", "2"]), (nw.Float32, [2.0, 2.0, 2.0])],
+)
+def test_lit(
+    constructor_with_lazy: Any, dtype: DType | None, expected_lit: list[Any]
+) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw)
+    result = df.with_columns(nw.lit(2, dtype).alias("lit"))
+    result_native = nw.to_native(result)
+    expected = {
+        "a": [1, 3, 2],
+        "b": [4, 4, 6],
+        "z": [7.0, 8.0, 9.0],
+        "lit": expected_lit,
+    }
+    compare_dicts(result_native, expected)
+
+
+def test_lit_error(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw)
+    with pytest.raises(
+        ValueError, match="numpy arrays are not supported as literal values"
+    ):
+        _ = df.with_columns(nw.lit(np.array([1, 2])).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit((1, 2)).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit([1, 2]).alias("lit"))
diff --git a/tests/frame/null_count_test.py b/tests/frame/null_count_test.py
new file mode 100644
index 00000000..5fc66daf
--- /dev/null
+++ b/tests/frame/null_count_test.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_null_count(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [None, 3, 2], "b": [4, 4, 6], "z": [7.0, None, 9]}
+    df_raw = constructor_with_pyarrow(data)
+    df = nw.from_native(df_raw, eager_only=True)
+    result = df.null_count()
+    expected = {"a": [1], "b": [0], "z": [1]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/pipe_test.py b/tests/frame/pipe_test.py
index 6f49966b..9dd66f10 100644
--- a/tests/frame/pipe_test.py
+++ b/tests/frame/pipe_test.py
@@ -9,8 +9,8 @@ data = {
 }
 
 
-def test_pipe(constructor: Any) -> None:
-    df = nw.from_native(constructor(data))
+def test_pipe(constructor_with_pyarrow: Any) -> None:
+    df = nw.from_native(constructor_with_pyarrow(data))
     columns = df.lazy().collect().columns
     result = df.pipe(lambda _df: _df.select([x for x in columns if len(x) == 2]))
     expected = {"ab": ["foo", "bars"]}
diff --git a/tests/frame/quantile_test.py b/tests/frame/quantile_test.py
new file mode 100644
index 00000000..c667f92d
--- /dev/null
+++ b/tests/frame/quantile_test.py
@@ -0,0 +1,36 @@
+from __future__ import annotations
+
+from typing import Any
+from typing import Literal
+
+import pytest
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+@pytest.mark.parametrize(
+    ("interpolation", "expected"),
+    [
+        ("lower", {"a": [1.0], "b": [4.0], "z": [7.0]}),
+        ("higher", {"a": [2.0], "b": [4.0], "z": [8.0]}),
+        ("midpoint", {"a": [1.5], "b": [4.0], "z": [7.5]}),
+        ("linear", {"a": [1.6], "b": [4.0], "z": [7.6]}),
+        ("nearest", {"a": [2.0], "b": [4.0], "z": [8.0]}),
+    ],
+)
+@pytest.mark.filterwarnings("ignore:the `interpolation=` argument to percentile")
+def test_quantile(
+    constructor: Any,
+    interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    expected: dict[str, list[float]],
+) -> None:
+    q = 0.3
+
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor(data)
+    df = nw.from_native(df_raw)
+    result = nw.to_native(
+        df.select(nw.all().quantile(quantile=q, interpolation=interpolation))
+    )
+    compare_dicts(result, expected)
diff --git a/tests/frame/slice_test.py b/tests/frame/slice_test.py
index 45390c56..4a911142 100644
--- a/tests/frame/slice_test.py
+++ b/tests/frame/slice_test.py
@@ -5,8 +5,10 @@ import pandas as pd
 import polars as pl
 import pyarrow as pa
 import pytest
+from pandas.testing import assert_series_equal
 
 import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
 from tests.utils import compare_dicts
 
 data = {
@@ -69,3 +71,23 @@ def test_gather_pandas_index() -> None:
     result = nw.from_native(df, eager_only=True)[[1, 2]]
     expected = {"a": [1, 2], "b": [4, 2]}
     compare_dicts(result, expected)
+
+
+def test_gather_rows_cols(constructor_with_pyarrow: Any) -> None:
+    native_df = constructor_with_pyarrow(data)
+    df = nw.from_native(native_df, eager_only=True)
+    is_pandas_wo_pyarrow = parse_version(pd.__version__) < parse_version("1.0.0")
+    if isinstance(native_df, pa.Table) or is_pandas_wo_pyarrow:
+        # PyArrowSeries do not have `to_pandas`
+        result = df[[0, 3, 1], 1].to_numpy()
+        expected = np.array([11, 14, 12])
+        assert np.array_equal(result, expected)
+        result = df[np.array([0, 3, 1]), "b"].to_numpy()
+        assert np.array_equal(result, expected)
+    else:
+        result = df[[0, 3, 1], 1].to_pandas()
+        expected_index = range(3) if isinstance(native_df, pl.DataFrame) else [0, 3, 1]
+        expected = pd.Series([11, 14, 12], name="b", index=expected_index)
+        assert_series_equal(result, expected, check_dtype=False)
+        result = df[np.array([0, 3, 1]), "b"].to_pandas()
+        assert_series_equal(result, expected, check_dtype=False)
diff --git a/tests/frame/tail_test.py b/tests/frame/tail_test.py
new file mode 100644
index 00000000..28f9edf3
--- /dev/null
+++ b/tests/frame/tail_test.py
@@ -0,0 +1,26 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_tail(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
+
+    df_raw = constructor_with_pyarrow(data)
+    df = nw.from_native(df_raw).lazy()
+
+    result = df.tail(2)
+    compare_dicts(result, expected)
+
+    result = df.collect().tail(2)  # type: ignore[assignment]
+    compare_dicts(result, expected)
+
+    result = df.collect().tail(-1)  # type: ignore[assignment]
+    compare_dicts(result, expected)
+
+    result = df.collect().select(nw.col("a").tail(2))  # type: ignore[assignment]
+    compare_dicts(result, {"a": expected["a"]})
diff --git a/tests/frame/test_common.py b/tests/frame/test_common.py
index 3234a64a..99a16de6 100644
--- a/tests/frame/test_common.py
+++ b/tests/frame/test_common.py
@@ -4,7 +4,6 @@ import re
 import warnings
 from typing import TYPE_CHECKING
 from typing import Any
-from typing import Literal
 
 import numpy as np
 import pandas as pd
@@ -21,8 +20,6 @@ from tests.utils import compare_dicts
 from tests.utils import maybe_get_modin_df
 
 if TYPE_CHECKING:
-    from narwhals.dtypes import DType
-    from narwhals.typing import IntoDataFrame
     from narwhals.typing import IntoFrameT
 
 df_pandas = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
@@ -92,45 +89,6 @@ def test_std(df_raw: Any) -> None:
     compare_dicts(result_native, expected)
 
 
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-@pytest.mark.parametrize(
-    ("dtype", "expected_lit"),
-    [(None, [2, 2, 2]), (nw.String, ["2", "2", "2"]), (nw.Float32, [2.0, 2.0, 2.0])],
-)
-def test_lit(df_raw: Any, dtype: DType | None, expected_lit: list[Any]) -> None:
-    df = nw.from_native(df_raw)
-    result = df.with_columns(nw.lit(2, dtype).alias("lit"))
-    result_native = nw.to_native(result)
-    expected = {
-        "a": [1, 3, 2],
-        "b": [4, 4, 6],
-        "z": [7.0, 8.0, 9.0],
-        "lit": expected_lit,
-    }
-    compare_dicts(result_native, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_lit_error(df_raw: Any) -> None:
-    df = nw.from_native(df_raw)
-    with pytest.raises(
-        ValueError, match="numpy arrays are not supported as literal values"
-    ):
-        _ = df.with_columns(nw.lit(np.array([1, 2])).alias("lit"))
-    with pytest.raises(
-        NotImplementedError, match="Nested datatypes are not supported yet."
-    ):
-        _ = df.with_columns(nw.lit((1, 2)).alias("lit"))
-    with pytest.raises(
-        NotImplementedError, match="Nested datatypes are not supported yet."
-    ):
-        _ = df.with_columns(nw.lit([1, 2]).alias("lit"))
-
-
 @pytest.mark.parametrize(
     "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
 )
@@ -177,31 +135,6 @@ def test_accepted_dataframes() -> None:
         nw.LazyFrame(array, is_polars=False, backend_version=(1,))
 
 
-@pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_mpd, df_pa])
-@pytest.mark.filterwarnings("ignore:.*Passing a BlockManager.*:DeprecationWarning")
-@pytest.mark.skipif(
-    parse_version(pd.__version__) < parse_version("2.0.0"),
-    reason="too old for pandas-pyarrow",
-)
-def test_convert_pandas(df_raw: Any) -> None:
-    result = nw.from_native(df_raw).to_pandas()  # type: ignore[union-attr]
-    expected = pd.DataFrame({"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]})
-    pd.testing.assert_frame_equal(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_polars, df_pandas, df_mpd, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_convert_numpy(df_raw: Any) -> None:
-    result = nw.from_native(df_raw, eager_only=True).to_numpy()
-    expected = np.array([[1, 3, 2], [4, 4, 6], [7.0, 8, 9]]).T
-    np.testing.assert_array_equal(result, expected)
-    assert result.dtype == "float64"
-    result = nw.from_native(df_raw, eager_only=True).__array__()
-    np.testing.assert_array_equal(result, expected)
-    assert result.dtype == "float64"
-
-
 @pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_mpd, df_lazy])
 def test_expr_binary(df_raw: Any) -> None:
     result = nw.from_native(df_raw).with_columns(
@@ -247,24 +180,6 @@ def test_expr_binary(df_raw: Any) -> None:
     compare_dicts(result_native, expected)
 
 
-@pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_lazy])
-def test_expr_unary(df_raw: Any) -> None:
-    result = (
-        nw.from_native(df_raw)
-        .with_columns(
-            a_mean=nw.col("a").mean(),
-            a_sum=nw.col("a").sum(),
-            b_nunique=nw.col("b").n_unique(),
-            z_min=nw.col("z").min(),
-            z_max=nw.col("z").max(),
-        )
-        .select(nw.col("a_mean", "a_sum", "b_nunique", "z_min", "z_max").unique())
-    )
-    result_native = nw.to_native(result)
-    expected = {"a_mean": [2], "a_sum": [6], "b_nunique": [2], "z_min": [7], "z_max": [9]}
-    compare_dicts(result_native, expected)
-
-
 @pytest.mark.parametrize("df_raw", [df_polars, df_pandas, df_mpd, df_lazy])
 def test_expr_transform(df_raw: Any) -> None:
     result = nw.from_native(df_raw).with_columns(
@@ -296,51 +211,6 @@ def test_expr_na(df_raw: Any) -> None:
     compare_dicts(result_nna, expected)
 
 
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_head(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.head(2))
-    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().head(2))
-    expected = {"a": [1, 3], "b": [4, 4], "z": [7.0, 8.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").head(2)))
-    expected = {"a": [1, 3]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_tail(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.tail(2))
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().tail(2))
-    expected = {"a": [3, 2], "b": [4, 6], "z": [8.0, 9]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().select(nw.col("a").tail(2)))
-    expected = {"a": [3, 2]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize(
-    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
-)
-def test_unique(df_raw: Any) -> None:
-    df = nw.from_native(df_raw).lazy()
-    result = nw.to_native(df.unique("b").sort("b"))
-    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
-    compare_dicts(result, expected)
-    result = nw.to_native(df.collect().unique("b").sort("b"))
-    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
-    compare_dicts(result, expected)
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas_na, df_lazy_na])
 def test_drop_nulls(df_raw: Any) -> None:
     df = nw.from_native(df_raw).lazy()
@@ -473,64 +343,6 @@ def test_library(df_raw: Any, df_raw_right: Any) -> None:
         df_left.join(df_right, left_on=["a"], right_on=["a"], how="inner")
 
 
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-def test_is_duplicated(df_raw: IntoDataFrame) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.concat([df, df.head(1)]).is_duplicated()
-    expected = np.array([True, False, False, True])
-    assert (result.to_numpy() == expected).all()
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize(("threshold", "expected"), [(0, False), (10, True)])
-def test_is_empty(df_raw: Any, threshold: Any, expected: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = df.filter(nw.col("a") > threshold).is_empty()
-    assert result == expected
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-def test_is_unique(df_raw: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.concat([df, df.head(1)]).is_unique()
-    expected = np.array([False, True, True, False])
-    assert (result.to_numpy() == expected).all()
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas_na, df_lazy_na.collect()])
-def test_null_count(df_raw: Any) -> None:
-    df = nw.from_native(df_raw, eager_only=True)
-    result = nw.to_native(df.null_count())
-    expected = {"a": [1], "b": [0], "z": [1]}
-    compare_dicts(result, expected)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize(
-    ("interpolation", "expected"),
-    [
-        ("lower", {"a": [1.0], "b": [4.0], "z": [7.0]}),
-        ("higher", {"a": [2.0], "b": [4.0], "z": [8.0]}),
-        ("midpoint", {"a": [1.5], "b": [4.0], "z": [7.5]}),
-        ("linear", {"a": [1.6], "b": [4.0], "z": [7.6]}),
-        ("nearest", {"a": [2.0], "b": [4.0], "z": [8.0]}),
-    ],
-)
-@pytest.mark.filterwarnings("ignore:the `interpolation=` argument to percentile")
-def test_quantile(
-    df_raw: Any,
-    interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
-    expected: dict[str, list[float]],
-) -> None:
-    q = 0.3
-
-    df = nw.from_native(df_raw)
-    result = nw.to_native(
-        df.select(nw.all().quantile(quantile=q, interpolation=interpolation))
-    )
-    compare_dicts(result, expected)
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
 @pytest.mark.parametrize(
     ("row", "column", "expected"),
diff --git a/tests/frame/to_dict_test.py b/tests/frame/to_dict_test.py
index b0950c5c..8fa31c33 100644
--- a/tests/frame/to_dict_test.py
+++ b/tests/frame/to_dict_test.py
@@ -3,16 +3,16 @@ from typing import Any
 import narwhals.stable.v1 as nw
 
 
-def test_to_dict(constructor: Any) -> None:
+def test_to_dict(constructor_with_pyarrow: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "c": [7.0, 8, 9]}
-    df = nw.from_native(constructor(data), eager_only=True)
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
     result = df.to_dict(as_series=False)
     assert result == data
 
 
-def test_to_dict_as_series(constructor: Any) -> None:
+def test_to_dict_as_series(constructor_with_pyarrow: Any) -> None:
     data = {"a": [1, 3, 2], "b": [4, 4, 6], "c": [7.0, 8, 9]}
-    df = nw.from_native(constructor(data), eager_only=True)
+    df = nw.from_native(constructor_with_pyarrow(data), eager_only=True)
     result = df.to_dict(as_series=True)
     assert isinstance(result["a"], nw.Series)
     assert isinstance(result["b"], nw.Series)
diff --git a/tests/frame/to_numpy_test.py b/tests/frame/to_numpy_test.py
new file mode 100644
index 00000000..5cfa69bf
--- /dev/null
+++ b/tests/frame/to_numpy_test.py
@@ -0,0 +1,21 @@
+from __future__ import annotations
+
+from typing import Any
+
+import numpy as np
+
+import narwhals.stable.v1 as nw
+
+
+def test_convert_numpy(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.1, 8, 9]}
+    df_raw = constructor_with_pyarrow(data)
+    result = nw.from_native(df_raw, eager_only=True).to_numpy()
+
+    expected = np.array([[1, 3, 2], [4, 4, 6], [7.1, 8, 9]]).T
+    np.testing.assert_array_equal(result, expected)
+    assert result.dtype == "float64"
+
+    result = nw.from_native(df_raw, eager_only=True).__array__()
+    np.testing.assert_array_equal(result, expected)
+    assert result.dtype == "float64"
diff --git a/tests/frame/to_pandas_test.py b/tests/frame/to_pandas_test.py
new file mode 100644
index 00000000..52ee9352
--- /dev/null
+++ b/tests/frame/to_pandas_test.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pandas as pd
+import pytest
+
+import narwhals.stable.v1 as nw
+from narwhals.utils import parse_version
+
+
+@pytest.mark.filterwarnings("ignore:.*Passing a BlockManager.*:DeprecationWarning")
+@pytest.mark.skipif(
+    parse_version(pd.__version__) < parse_version("2.0.0"),
+    reason="too old for pandas-pyarrow",
+)
+def test_convert_pandas(constructor_with_pyarrow: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_pyarrow(data)
+    result = nw.from_native(df_raw).to_pandas()  # type: ignore[union-attr]
+
+    if constructor_with_pyarrow.__name__.startswith("pandas"):
+        expected = constructor_with_pyarrow(data)
+    else:
+        expected = pd.DataFrame(data)
+
+    pd.testing.assert_frame_equal(result, expected)
diff --git a/tests/frame/unique_test.py b/tests/frame/unique_test.py
new file mode 100644
index 00000000..5f3911c8
--- /dev/null
+++ b/tests/frame/unique_test.py
@@ -0,0 +1,18 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals.stable.v1 as nw
+from tests.utils import compare_dicts
+
+
+def test_unique(constructor_with_lazy: Any) -> None:
+    data = {"a": [1, 3, 2], "b": [4, 4, 6], "z": [7.0, 8, 9]}
+    df_raw = constructor_with_lazy(data)
+    df = nw.from_native(df_raw).lazy()
+    result = nw.to_native(df.unique("b").sort("b"))
+    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
+    compare_dicts(result, expected)
+    result = nw.to_native(df.collect().unique("b").sort("b"))
+    expected = {"a": [1, 2], "b": [4, 6], "z": [7.0, 9.0]}
+    compare_dicts(result, expected)
diff --git a/tests/frame/with_row_index_test.py b/tests/frame/with_row_index_test.py
index 1b0ad579..ef54557b 100644
--- a/tests/frame/with_row_index_test.py
+++ b/tests/frame/with_row_index_test.py
@@ -9,9 +9,9 @@ data = {
 }
 
 
-def test_with_row_index(constructor: Any) -> None:
-    result = nw.from_native(constructor(data)).with_row_index()
+def test_with_row_index(constructor_with_pyarrow: Any) -> None:
+    result = nw.from_native(constructor_with_pyarrow(data)).with_row_index()
     expected = {"a": ["foo", "bars"], "ab": ["foo", "bars"], "index": [0, 1]}
     compare_dicts(result, expected)
-    result = nw.from_native(constructor(data)).lazy().with_row_index()
+    result = nw.from_native(constructor_with_pyarrow(data)).lazy().with_row_index()
     compare_dicts(result, expected)
diff --git a/tests/hypothesis/test_join.py b/tests/hypothesis/test_join.py
index cf019583..d5db53e3 100644
--- a/tests/hypothesis/test_join.py
+++ b/tests/hypothesis/test_join.py
@@ -3,12 +3,14 @@ from __future__ import annotations
 import pandas as pd
 import polars as pl
 import pytest
+from hypothesis import assume
 from hypothesis import given
 from hypothesis import strategies as st
 from pandas.testing import assert_frame_equal
 
 import narwhals.stable.v1 as nw
 from narwhals.utils import parse_version
+from tests.utils import compare_dicts
 
 pl_version = parse_version(pl.__version__)
 pd_version = parse_version(pd.__version__)
@@ -116,3 +118,48 @@ def test_cross_join(  # pragma: no cover
     )
 
     assert_frame_equal(dframe_pd1, dframe_pd2)
+
+
+@given(  # type: ignore[misc]
+    a_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    c_left_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    a_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    b_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    d_right_data=st.lists(st.integers(min_value=0, max_value=5), min_size=3, max_size=3),
+    left_key=st.lists(
+        st.sampled_from(["a", "b", "c"]), min_size=1, max_size=3, unique=True
+    ),
+    right_key=st.lists(
+        st.sampled_from(["a", "b", "d"]), min_size=1, max_size=3, unique=True
+    ),
+)
+@pytest.mark.slow()
+def test_left_join(  # pragma: no cover
+    a_left_data: list[int],
+    b_left_data: list[int],
+    c_left_data: list[int],
+    a_right_data: list[int],
+    b_right_data: list[int],
+    d_right_data: list[int],
+    left_key: list[str],
+    right_key: list[str],
+) -> None:
+    assume(len(left_key) == len(right_key))
+    data_left = {"a": a_left_data, "b": b_left_data, "c": c_left_data}
+    data_right = {"a": a_right_data, "b": b_right_data, "d": d_right_data}
+    result_pd = nw.from_native(pd.DataFrame(data_left), eager_only=True).join(
+        nw.from_native(pd.DataFrame(data_right), eager_only=True),
+        how="left",
+        left_on=left_key,
+        right_on=right_key,
+    )
+    result_pl = nw.to_native(
+        nw.from_native(pl.DataFrame(data_left), eager_only=True).join(
+            nw.from_native(pl.DataFrame(data_right), eager_only=True),
+            how="left",
+            left_on=left_key,
+            right_on=right_key,
+        )
+    ).select(pl.all().fill_null(float("nan")))
+    compare_dicts(result_pd.to_dict(as_series=False), result_pl.to_dict(as_series=False))
diff --git a/tests/series/head_test.py b/tests/series/head_test.py
new file mode 100644
index 00000000..3418bd92
--- /dev/null
+++ b/tests/series/head_test.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+
+
+@pytest.mark.parametrize("n", [2, -1])
+def test_head(constructor_series_with_pyarrow: Any, n: int) -> None:
+    s = nw.from_native(constructor_series_with_pyarrow([1, 2, 3]), series_only=True)
+
+    assert s.head(n).to_list() == [1, 2]
diff --git a/tests/series/null_count_test.py b/tests/series/null_count_test.py
new file mode 100644
index 00000000..b2bf84bd
--- /dev/null
+++ b/tests/series/null_count_test.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from typing import Any
+
+import narwhals as nw
+
+
+def test_null_count(constructor_series_with_pyarrow: Any) -> None:
+    data = [1, 2, None]
+    series = nw.from_native(constructor_series_with_pyarrow(data), series_only=True)
+    result = series.null_count()
+    assert result == 1
diff --git a/tests/series/tail_test.py b/tests/series/tail_test.py
new file mode 100644
index 00000000..21759321
--- /dev/null
+++ b/tests/series/tail_test.py
@@ -0,0 +1,14 @@
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+import narwhals as nw
+
+
+@pytest.mark.parametrize("n", [2, -1])
+def test_tail(constructor_series_with_pyarrow: Any, n: int) -> None:
+    s = nw.from_native(constructor_series_with_pyarrow([1, 2, 3]), series_only=True)
+
+    assert s.tail(n).to_list() == [2, 3]
diff --git a/tests/series/test_common.py b/tests/series/test_common.py
index c69f852e..2a573958 100644
--- a/tests/series/test_common.py
+++ b/tests/series/test_common.py
@@ -172,13 +172,6 @@ def test_is_unique(df_raw: Any) -> None:
     assert (result.to_numpy() == expected).all()
 
 
-@pytest.mark.parametrize("s_raw", [pd.Series([1, 2, None]), pl.Series([1, 2, None])])
-def test_null_count(s_raw: Any) -> None:
-    series = nw.from_native(s_raw, series_only=True)
-    result = series.null_count()
-    assert result == 1
-
-
 @pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
 def test_is_first_distinct(df_raw: Any) -> None:
     series = nw.from_native(df_raw["b"], series_only=True)
@@ -299,21 +292,3 @@ def test_item(df_raw: Any, index: int, expected: int) -> None:
         match=re.escape("can only call '.item()' if the Series is of length 1,"),
     ):
         s.item(None)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize("n", [1, 2, 3, 10])
-def test_head(df_raw: Any, n: int) -> None:
-    s_raw = df_raw["z"]
-    s = nw.from_native(s_raw, allow_series=True)
-
-    assert s.head(n) == nw.from_native(s_raw.head(n), series_only=True)
-
-
-@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
-@pytest.mark.parametrize("n", [1, 2, 3, 10])
-def test_tail(df_raw: Any, n: int) -> None:
-    s_raw = df_raw["z"]
-    s = nw.from_native(s_raw, allow_series=True)
-
-    assert s.tail(n) == nw.from_native(s_raw.tail(n), series_only=True)
diff --git a/tests/utils.py b/tests/utils.py
index 202c332e..d9984769 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -14,9 +14,8 @@ if TYPE_CHECKING:
 
 def zip_strict(left: Sequence[Any], right: Sequence[Any]) -> Iterator[Any]:
     if len(left) != len(right):
-        raise ValueError(
-            "left len != right len", len(left), len(right)
-        )  # pragma: no cover
+        msg = f"left {len(left)=} != right {len(right)=}"  # pragma: no cover
+        raise ValueError(msg)  # pragma: no cover
     return zip(left, right)