Repository: narwhals-dev/narwhals
Pull request #2271 (2025-03-23 15:27:25)

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



Patch:
diff --git a/.github/workflows/mkdocs.yml b/.github/workflows/mkdocs.yml
index f05ae31e..11e4e424 100644
--- a/.github/workflows/mkdocs.yml
+++ b/.github/workflows/mkdocs.yml
@@ -29,6 +29,6 @@ jobs:
           restore-keys: |
             mkdocs-material-
       - name: Install dependencies
-        run: uv pip install -e ".[dask,duckdb]" --group docs --system
+        run: uv pip install -e ".[dask,duckdb,sqlframe]" --group docs --system
       - name: Deploy docs
         run: mkdocs gh-deploy --force
diff --git a/docs/basics/order_dependence.md b/docs/basics/order_dependence.md
new file mode 100644
index 00000000..e5610865
--- /dev/null
+++ b/docs/basics/order_dependence.md
@@ -0,0 +1,55 @@
+# Order-dependence
+
+Narwhals has four main public classes:
+
+- `Expr`: what gets created when you write `nw.col('a')`.
+- `DataFrame`: in-memory, eager dataframe with a well-defined row order which
+  is preserved across `with_columns` and `select` operations.
+- `LazyFrame`: a dataframe which makes no assumptions about row-ordering. This
+  allows it to be backed by SQL engines.
+- `Series`: 1-dimensional in-memory structure with a defined row order. This is
+  what you get if you extract a single column from a `DataFrame`.
+
+Row order is important to think about when performing operations which rely on it,
+such as:
+
+- `diff`, `shift`.
+- `cum_sum`, `cum_min`, ...
+- `rolling_sum`, `rolling_min`, ...
+- `is_first_distinct`, `is_last_distinct`.
+
+When row-order is defined, as is the case for `DataFrame`, these operations pose
+no issue.
+
+```python exec="1" result="python" session="order_dependence" source="above"
+import narwhals as nw
+import pandas as pd
+
+df_pd = pd.DataFrame({"a": [1, 3, 4], "i": [0, 1, 2]})
+df = nw.from_native(df_pd)
+print(df.with_columns(a_cum_sum=nw.col("a").cum_sum()))
+```
+
+When row order is undefined however, then these operations do not have a defined
+result. To make them well-defined, you need to follow them with `over` in which
+you specify `order_by`. For example:
+
+- `nw.col('a').cum_sum()` can only be executed by a `DataFrame`.
+- `nw.col('a').cum_sum().over(order_by="i")` can only be executed by either a `DataFrame`
+  or a `LazyFrame`.
+
+```python exec="1" result="python" session="order_dependence" source="above"
+from sqlframe.duckdb import DuckDBSession
+
+session = DuckDBSession()
+sqlframe_df = session.createDataFrame(df_pd)
+lf = nw.from_native(sqlframe_df)
+result = lf.with_columns(a_cum_sum=nw.col("a").cum_sum().over(order_by="i"))
+print(result)
+print(result.collect("pandas"))
+```
+
+When writing an order-dependent function, if you want it to be executable by `LazyFrame`
+(and not just `DataFrame`), make sure that all order-dependent expressions are followed
+by `over` with `order_by` specified. If you forget to, don't worry, Narwhals will
+give you a loud and clear error message.
diff --git a/mkdocs.yml b/mkdocs.yml
index db26af50..a4b1a146 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -11,6 +11,7 @@ nav:
     - basics/series.md
     - basics/complete_example.md
     - basics/dataframe_conversion.md
+    - basics/order_dependence.md
   - Pandas-like concepts:
     - pandas_like_concepts/pandas_index.md
     - pandas_like_concepts/improve_group_by_operation.md
diff --git a/narwhals/_arrow/dataframe.py b/narwhals/_arrow/dataframe.py
index 58849c6d..418000cc 100644
--- a/narwhals/_arrow/dataframe.py
+++ b/narwhals/_arrow/dataframe.py
@@ -416,7 +416,7 @@ class ArrowDataFrame(EagerDataFrame["ArrowSeries", "ArrowExpr", "pa.Table"]):
         self: Self,
         other: Self,
         *,
-        how: Literal["left", "inner", "cross", "anti", "semi"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
@@ -426,6 +426,7 @@ class ArrowDataFrame(EagerDataFrame["ArrowSeries", "ArrowExpr", "pa.Table"]):
             "semi": "left semi",
             "inner": "inner",
             "left": "left outer",
+            "full": "full outer",
         }
 
         if how == "cross":
@@ -450,6 +451,7 @@ class ArrowDataFrame(EagerDataFrame["ArrowSeries", "ArrowExpr", "pa.Table"]):
                 .drop([key_token])
             )
 
+        coalesce_keys = how != "full"  # polars full join does not coalesce keys
         return self._from_native_frame(
             self.native.join(
                 other.native,
@@ -457,6 +459,7 @@ class ArrowDataFrame(EagerDataFrame["ArrowSeries", "ArrowExpr", "pa.Table"]):
                 right_keys=right_on,  # type: ignore[arg-type]
                 join_type=how_to_join_map[how],
                 right_suffix=suffix,
+                coalesce_keys=coalesce_keys,
             ),
         )
 
diff --git a/narwhals/_compliant/dataframe.py b/narwhals/_compliant/dataframe.py
index 4abc9021..39e35717 100644
--- a/narwhals/_compliant/dataframe.py
+++ b/narwhals/_compliant/dataframe.py
@@ -107,7 +107,7 @@ class CompliantDataFrame(
         self: Self,
         other: Self,
         *,
-        how: Literal["left", "inner", "cross", "anti", "semi"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
diff --git a/narwhals/_dask/dataframe.py b/narwhals/_dask/dataframe.py
index 6103afa7..a6b11116 100644
--- a/narwhals/_dask/dataframe.py
+++ b/narwhals/_dask/dataframe.py
@@ -17,7 +17,9 @@ from narwhals._pandas_like.utils import select_columns_by_name
 from narwhals.typing import CompliantDataFrame
 from narwhals.typing import CompliantLazyFrame
 from narwhals.utils import Implementation
+from narwhals.utils import _remap_full_join_keys
 from narwhals.utils import check_column_exists
+from narwhals.utils import check_column_names_are_unique
 from narwhals.utils import generate_temporary_column_name
 from narwhals.utils import not_implemented
 from narwhals.utils import parse_columns_to_drop
@@ -255,7 +257,7 @@ class DaskLazyFrame(CompliantLazyFrame["DaskExpr", "dd.DataFrame"]):
         self: Self,
         other: Self,
         *,
-        how: Literal["left", "inner", "cross", "anti", "semi"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
@@ -350,6 +352,30 @@ class DaskLazyFrame(CompliantLazyFrame["DaskExpr", "dd.DataFrame"]):
                     extra.append(f"{right_key}_right")
             return self._from_native_frame(result_native.drop(columns=extra))
 
+        if how == "full":
+            # dask does not retain keys post-join
+            # we must append the suffix to each key before-hand
+
+            # help mypy
+            assert left_on is not None  # noqa: S101
+            assert right_on is not None  # noqa: S101
+
+            right_on_mapper = _remap_full_join_keys(left_on, right_on, suffix)
+
+            other_native = other._native_frame
+            other_native = other_native.rename(columns=right_on_mapper)
+            check_column_names_are_unique(other_native.columns)
+            right_on = list(right_on_mapper.values())  # we now have the suffixed keys
+            return self._from_native_frame(
+                self._native_frame.merge(
+                    other_native,
+                    left_on=left_on,
+                    right_on=right_on,
+                    how="outer",
+                    suffixes=("", suffix),
+                ),
+            )
+
         return self._from_native_frame(
             self._native_frame.merge(
                 other._native_frame,
diff --git a/narwhals/_duckdb/dataframe.py b/narwhals/_duckdb/dataframe.py
index d7894c34..ff8e35a1 100644
--- a/narwhals/_duckdb/dataframe.py
+++ b/narwhals/_duckdb/dataframe.py
@@ -252,14 +252,16 @@ class DuckDBLazyFrame(CompliantLazyFrame["DuckDBExpr", "duckdb.DuckDBPyRelation"
         self: Self,
         other: Self,
         *,
-        how: Literal["left", "inner", "cross", "anti", "semi"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
     ) -> Self:
         original_alias = self._native_frame.alias
 
-        if how == "cross":
+        native_how = "outer" if how == "full" else how
+
+        if native_how == "cross":
             if self._backend_version < (1, 1, 4):
                 msg = f"DuckDB>=1.1.4 is required for cross-join, found version: {self._backend_version}"
                 raise NotImplementedError(msg)
@@ -274,16 +276,20 @@ class DuckDBLazyFrame(CompliantLazyFrame["DuckDBExpr", "duckdb.DuckDBPyRelation"
             conditions = [
                 f'lhs."{left}" = rhs."{right}"' for left, right in zip(left_on, right_on)
             ]
+
             condition = " and ".join(conditions)
             rel = self._native_frame.set_alias("lhs").join(
-                other._native_frame.set_alias("rhs"), condition=condition, how=how
+                other._native_frame.set_alias("rhs"), condition=condition, how=native_how
             )
 
-        if how in {"inner", "left", "cross"}:
+        if native_how in {"inner", "left", "cross", "outer"}:
             select = [f'lhs."{x}"' for x in self._native_frame.columns]
             for col in other._native_frame.columns:
-                if col in self._native_frame.columns and (
-                    right_on is None or col not in right_on
+                col_in_lhs: bool = col in self._native_frame.columns
+                if native_how == "outer" and not col_in_lhs:
+                    select.append(f'rhs."{col}"')
+                elif (native_how == "outer") or (
+                    col_in_lhs and (right_on is None or col not in right_on)
                 ):
                     select.append(f'rhs."{col}" as "{col}{suffix}"')
                 elif right_on is None or col not in right_on:
diff --git a/narwhals/_pandas_like/dataframe.py b/narwhals/_pandas_like/dataframe.py
index 52242c1b..47e0d5c1 100644
--- a/narwhals/_pandas_like/dataframe.py
+++ b/narwhals/_pandas_like/dataframe.py
@@ -28,6 +28,7 @@ from narwhals.dependencies import is_numpy_array_1d
 from narwhals.exceptions import InvalidOperationError
 from narwhals.exceptions import ShapeError
 from narwhals.utils import Implementation
+from narwhals.utils import _remap_full_join_keys
 from narwhals.utils import check_column_exists
 from narwhals.utils import generate_temporary_column_name
 from narwhals.utils import import_dtypes_module
@@ -600,7 +601,7 @@ class PandasLikeDataFrame(EagerDataFrame["PandasLikeSeries", "PandasLikeExpr", "
         self: Self,
         other: Self,
         *,
-        how: Literal["left", "inner", "cross", "anti", "semi"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
@@ -723,6 +724,29 @@ class PandasLikeDataFrame(EagerDataFrame["PandasLikeSeries", "PandasLikeExpr", "
                     extra.append(f"{right_key}{suffix}")
             return self._from_native_frame(result_native.drop(columns=extra))
 
+        if how == "full":
+            # Pandas coalesces keys in full joins unless there's no collision
+
+            # help mypy
+            assert left_on is not None  # noqa: S101
+            assert right_on is not None  # noqa: S101
+
+            right_on_mapper = _remap_full_join_keys(left_on, right_on, suffix)
+
+            other_native = other._native_frame
+            other_native = other_native.rename(columns=right_on_mapper)
+            check_column_names_are_unique(other_native.columns)
+            right_on = list(right_on_mapper.values())  # we now have the suffixed keys
+            return self._from_native_frame(
+                self._native_frame.merge(
+                    other_native,
+                    left_on=left_on,
+                    right_on=right_on,
+                    how="outer",
+                    suffixes=("", suffix),
+                ),
+            )
+
         return self._from_native_frame(
             self.native.merge(
                 other._native_frame,
diff --git a/narwhals/_polars/dataframe.py b/narwhals/_polars/dataframe.py
index c0f702c8..352e3ab2 100644
--- a/narwhals/_polars/dataframe.py
+++ b/narwhals/_polars/dataframe.py
@@ -62,7 +62,6 @@ class PolarsDataFrame:
     item: Method[Any]
     iter_rows: Method[Iterator[tuple[Any, ...]] | Iterator[Mapping[str, Any]]]
     is_unique: Method[PolarsSeries]
-    join: Method[Self]
     join_asof: Method[Self]
     rename: Method[Self]
     row: Method[tuple[Any, ...]]
@@ -417,6 +416,29 @@ class PolarsDataFrame:
     def to_polars(self: Self) -> pl.DataFrame:
         return self.native
 
+    def join(
+        self: Self,
+        other: Self,
+        *,
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
+        left_on: Sequence[str] | None,
+        right_on: Sequence[str] | None,
+        suffix: str,
+    ) -> Self:
+        how_native = (
+            "outer" if (self._backend_version < (1, 0, 0) and how == "full") else how
+        )
+
+        return self._from_native_frame(
+            self._native_frame.join(
+                other=other._native_frame,
+                how=how_native,  # type: ignore[arg-type]
+                left_on=left_on,
+                right_on=right_on,
+                suffix=suffix,
+            )
+        )
+
 
 class PolarsLazyFrame:
     drop_nulls: Method[Self]
@@ -424,7 +446,6 @@ class PolarsLazyFrame:
     filter: Method[Self]
     gather_every: Method[Self]
     head: Method[Self]
-    join: Method[Self]
     join_asof: Method[Self]
     rename: Method[Self]
     select: Method[Self]
@@ -616,3 +637,26 @@ class PolarsLazyFrame:
 
     def aggregate(self: Self, *exprs: Any) -> Self:
         return self.select(*exprs)
+
+    def join(
+        self: Self,
+        other: Self,
+        *,
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
+        left_on: Sequence[str] | None,
+        right_on: Sequence[str] | None,
+        suffix: str,
+    ) -> Self:
+        how_native = (
+            "outer" if (self._backend_version < (1, 0, 0) and how == "full") else how
+        )
+
+        return self._from_native_frame(
+            self._native_frame.join(
+                other=other._native_frame,
+                how=how_native,  # type: ignore[arg-type]
+                left_on=left_on,
+                right_on=right_on,
+                suffix=suffix,
+            )
+        )
diff --git a/narwhals/_spark_like/dataframe.py b/narwhals/_spark_like/dataframe.py
index cd1577c8..403c67f4 100644
--- a/narwhals/_spark_like/dataframe.py
+++ b/narwhals/_spark_like/dataframe.py
@@ -1,6 +1,8 @@
 from __future__ import annotations
 
 import warnings
+from functools import reduce
+from operator import and_
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Iterator
@@ -328,7 +330,7 @@ class SparkLikeLazyFrame(CompliantLazyFrame["SparkLikeExpr", "SQLFrameDataFrame"
     def join(
         self: Self,
         other: Self,
-        how: Literal["inner", "left", "cross", "semi", "anti"],
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"],
         left_on: Sequence[str] | None,
         right_on: Sequence[str] | None,
         suffix: str,
@@ -339,14 +341,23 @@ class SparkLikeLazyFrame(CompliantLazyFrame["SparkLikeExpr", "SQLFrameDataFrame"
         left_columns = self.columns
         right_columns = other.columns
 
+        right_on_: list[str] = list(right_on) if right_on is not None else []
+        left_on_: list[str] = list(left_on) if left_on is not None else []
+
         # create a mapping for columns on other
         # `right_on` columns will be renamed as `left_on`
         # the remaining columns will be either added the suffix or left unchanged.
+        right_cols_to_rename = (
+            [c for c in right_columns if c not in right_on_]
+            if how != "full"
+            else right_columns
+        )
+
         rename_mapping = {
-            **dict(zip(right_on or [], left_on or [])),
+            **dict(zip(right_on_, left_on_)),
             **{
                 colname: f"{colname}{suffix}" if colname in left_columns else colname
-                for colname in list(set(right_columns).difference(set(right_on or [])))
+                for colname in right_cols_to_rename
             },
         }
         other_native = other_native.select(
@@ -363,12 +374,30 @@ class SparkLikeLazyFrame(CompliantLazyFrame["SparkLikeExpr", "SQLFrameDataFrame"
                 [
                     rename_mapping[colname]
                     for colname in right_columns
-                    if colname not in (right_on or [])
+                    if colname not in right_on_
                 ]
             )
-        on = list(left_on) if left_on else None
+        elif how == "full":
+            col_order.extend(rename_mapping.values())
+
+        right_on_remapped = [rename_mapping[c] for c in right_on_]
+        on_ = (
+            reduce(
+                and_,
+                (
+                    getattr(self_native, left_key) == getattr(other_native, right_key)
+                    for left_key, right_key in zip(left_on_, right_on_remapped)
+                ),
+            )
+            if how == "full"
+            else None
+            if how == "cross"
+            else left_on_
+        )
+        how_native = "full_outer" if how == "full" else how
+
         return self._from_native_frame(
-            self_native.join(other_native, on=on, how=how).select(col_order)
+            self_native.join(other_native, on=on_, how=how_native).select(col_order)
         )
 
     def explode(self: Self, columns: Sequence[str]) -> Self:
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index a43cc331..52fcc962 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -237,7 +237,7 @@ class BaseFrame(Generic[_FrameT]):
         self: Self,
         other: Self,
         on: str | list[str] | None = None,
-        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"] = "inner",
         *,
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
@@ -247,7 +247,9 @@ class BaseFrame(Generic[_FrameT]):
         left_on = [left_on] if isinstance(left_on, str) else left_on
         right_on = [right_on] if isinstance(right_on, str) else right_on
 
-        if how not in (_supported_joins := ("inner", "left", "cross", "anti", "semi")):
+        if how not in (
+            _supported_joins := ("inner", "left", "full", "cross", "anti", "semi")
+        ):
             msg = f"Only the following join strategies are supported: {_supported_joins}; found '{how}'."
             raise NotImplementedError(msg)
 
@@ -1622,7 +1624,7 @@ class DataFrame(BaseFrame[DataFrameT]):
         self: Self,
         other: Self,
         on: str | list[str] | None = None,
-        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"] = "inner",
         *,
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
@@ -1638,6 +1640,7 @@ class DataFrame(BaseFrame[DataFrameT]):
 
                   * *inner*: Returns rows that have matching values in both tables.
                   * *left*: Returns all rows from the left table, and the matched rows from the right table.
+                  * *full*: Returns all rows in both dataframes, with the suffix appended to the right join keys.
                   * *cross*: Returns the Cartesian product of rows from both tables.
                   * *semi*: Filter rows that have a match in the right table.
                   * *anti*: Filter rows that do not have a match in the right table.
@@ -2909,7 +2912,7 @@ class LazyFrame(BaseFrame[FrameT]):
         self: Self,
         other: Self,
         on: str | list[str] | None = None,
-        how: Literal["inner", "left", "cross", "semi", "anti"] = "inner",
+        how: Literal["inner", "left", "full", "cross", "semi", "anti"] = "inner",
         *,
         left_on: str | list[str] | None = None,
         right_on: str | list[str] | None = None,
@@ -2925,6 +2928,7 @@ class LazyFrame(BaseFrame[FrameT]):
 
                   * *inner*: Returns rows that have matching values in both tables.
                   * *left*: Returns all rows from the left table, and the matched rows from the right table.
+                  * *full*: Returns all rows in both dataframes, with the suffix appended to the right join keys.
                   * *cross*: Returns the Cartesian product of rows from both tables.
                   * *semi*: Filter rows that have a match in the right table.
                   * *anti*: Filter rows that do not have a match in the right table.
diff --git a/narwhals/expr.py b/narwhals/expr.py
index e31aa914..bf67bb93 100644
--- a/narwhals/expr.py
+++ b/narwhals/expr.py
@@ -905,6 +905,10 @@ class Expr:
     def cum_sum(self: Self, *, reverse: bool = False) -> Self:
         """Return cumulative sum.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             reverse: reverse the operation
 
@@ -936,6 +940,10 @@ class Expr:
     def diff(self: Self) -> Self:
         """Returns the difference between each element and the previous one.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Returns:
             A new expression.
 
@@ -979,6 +987,10 @@ class Expr:
     def shift(self: Self, n: int) -> Self:
         """Shift values by `n` positions.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             n: Number of positions to shift values by.
 
@@ -1520,16 +1532,18 @@ class Expr:
 
     def over(
         self: Self,
-        *partition_by: str | Iterable[str],
-        _order_by: str | None = None,
+        *partition_by: str | Sequence[str],
+        order_by: str | Sequence[str] | None = None,
     ) -> Self:
-        """Compute expressions over the given groups.
+        """Compute expressions over the given groups (optionally with given order).
 
         Arguments:
             partition_by: Names of columns to compute window expression over.
                 Must be names of columns, as opposed to expressions -
                 so, this is a bit less flexible than Polars' `Expr.over`.
-            _order_by: Unused, but this is building up to something.
+            order_by: Column(s) to order window functions by.
+                For lazy backends, this argument is required when `over` is applied
+                to order-dependent functions, see [order-dependence](../basics/order_dependence.md).
 
         Returns:
             A new expression.
@@ -1567,14 +1581,14 @@ class Expr:
             raise LengthChangingExprError(msg)
 
         flat_partition_by = flatten(partition_by)
-        order_by = [_order_by] if isinstance(_order_by, str) else _order_by
-        if not flat_partition_by and not _order_by:  # pragma: no cover
+        flat_order_by = [order_by] if isinstance(order_by, str) else order_by
+        if not flat_partition_by and not flat_order_by:  # pragma: no cover
             msg = "At least one of `partition_by` or `order_by` must be specified."
             raise ValueError(msg)
 
         kind = ExprKind.TRANSFORM
         n_open_windows = self._metadata.n_open_windows
-        if _order_by is not None and self._metadata.kind.is_window():
+        if flat_order_by is not None and self._metadata.kind.is_window():
             n_open_windows -= 1
         current_meta = self._metadata
         next_meta = ExprMetadata(
@@ -1586,7 +1600,7 @@ class Expr:
         return self.__class__(
             lambda plx: self._to_compliant_expr(plx)
             ._with_metadata(current_meta)
-            .over(flat_partition_by, order_by),
+            .over(flat_partition_by, flat_order_by),
             next_meta,
         )
 
@@ -1672,6 +1686,10 @@ class Expr:
     def is_first_distinct(self: Self) -> Self:
         r"""Return a boolean mask indicating the first occurrence of each distinct value.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Returns:
             A new expression.
 
@@ -1701,6 +1719,10 @@ class Expr:
     def is_last_distinct(self: Self) -> Self:
         r"""Return a boolean mask indicating the last occurrence of each distinct value.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Returns:
             A new expression.
 
@@ -2039,6 +2061,10 @@ class Expr:
     def cum_count(self: Self, *, reverse: bool = False) -> Self:
         r"""Return the cumulative count of the non-null values in the column.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             reverse: reverse the operation
 
@@ -2072,6 +2098,10 @@ class Expr:
     def cum_min(self: Self, *, reverse: bool = False) -> Self:
         r"""Return the cumulative min of the non-null values in the column.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             reverse: reverse the operation
 
@@ -2105,6 +2135,10 @@ class Expr:
     def cum_max(self: Self, *, reverse: bool = False) -> Self:
         r"""Return the cumulative max of the non-null values in the column.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             reverse: reverse the operation
 
@@ -2138,6 +2172,10 @@ class Expr:
     def cum_prod(self: Self, *, reverse: bool = False) -> Self:
         r"""Return the cumulative product of the non-null values in the column.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             reverse: reverse the operation
 
@@ -2187,6 +2225,10 @@ class Expr:
         The window at a given row will include the row itself and the `window_size - 1`
         elements before it.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             window_size: The length of the window in number of elements. It must be a
                 strictly positive integer.
@@ -2249,6 +2291,10 @@ class Expr:
         The window at a given row will include the row itself and the `window_size - 1`
         elements before it.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             window_size: The length of the window in number of elements. It must be a
                 strictly positive integer.
@@ -2312,6 +2358,10 @@ class Expr:
         The window at a given row will include the row itself and the `window_size - 1`
         elements before it.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             window_size: The length of the window in number of elements. It must be a
                 strictly positive integer.
@@ -2374,6 +2424,10 @@ class Expr:
         The window at a given row will include the row itself and the `window_size - 1`
         elements before it.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             window_size: The length of the window in number of elements. It must be a
                 strictly positive integer.
@@ -2430,6 +2484,10 @@ class Expr:
         Notes:
             The resulting dtype may differ between backends.
 
+        !!! info
+            For lazy backends, this operation must be followed by `Expr.over` with
+            `order_by` specified, see [order-dependence](../basics/order_dependence.md).
+
         Arguments:
             method: The method used to assign ranks to tied elements.
                 The following methods are available (default is 'average'):
diff --git a/narwhals/utils.py b/narwhals/utils.py
index 5e75519b..ce996e92 100644
--- a/narwhals/utils.py
+++ b/narwhals/utils.py
@@ -1511,6 +1511,28 @@ def supports_arrow_c_stream(obj: Any) -> TypeIs[ArrowStreamExportable]:
     return _hasattr_static(obj, "__arrow_c_stream__")
 
 
+def _remap_full_join_keys(
+    left_on: Sequence[str], right_on: Sequence[str], suffix: str
+) -> dict[str, str]:
+    """Remap join keys to avoid collisions.
+
+    If left keys collide with the right keys, append the suffix.
+    If there's no collision, let the right keys be.
+
+    Arguments:
+        left_on: Left keys.
+        right_on: Right keys.
+        suffix: Suffix to append to right keys.
+
+    Returns:
+        A map of old to new right keys.
+    """
+    right_keys_suffixed = (
+        f"{key}{suffix}" if key in left_on else key for key in right_on
+    )
+    return dict(zip(right_on, right_keys_suffixed))
+
+
 # TODO @dangotbanned: Extend with runtime behavior for `v1.*`
 # See `narwhals.exceptions.NarwhalsUnstableWarning`
 def unstable(fn: _Fn, /) -> _Fn:
diff --git a/pyproject.toml b/pyproject.toml
index bc172147..df97a521 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -82,6 +82,7 @@ docs = [
   "mkdocs-material",
   "mkdocstrings[python]",
   "mkdocstrings-python>=1.16",
+  "sqlframe",
   "pandas",
   "polars>=1.0.0",
   "pyarrow",


Test patch:
diff --git a/tests/expr_and_series/cum_max_test.py b/tests/expr_and_series/cum_max_test.py
index 8bc1ecc9..a9105fb9 100644
--- a/tests/expr_and_series/cum_max_test.py
+++ b/tests/expr_and_series/cum_max_test.py
@@ -81,7 +81,7 @@ def test_lazy_cum_max_grouped(
         )
     )
     result = df.with_columns(
-        nw.col("a").cum_max(reverse=reverse).over("g", _order_by="b")
+        nw.col("a").cum_max(reverse=reverse).over("g", order_by="b")
     ).sort("i")
     expected = {"a": expected_a, "b": [1, 0, 2], "i": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -129,7 +129,7 @@ def test_lazy_cum_max_ordered_by_nulls(
         )
     )
     result = df.with_columns(
-        nw.col("a").cum_max(reverse=reverse).over("g", _order_by="b")
+        nw.col("a").cum_max(reverse=reverse).over("g", order_by="b")
     ).sort("i")
     expected = {
         "a": expected_a,
@@ -176,7 +176,7 @@ def test_lazy_cum_max_ungrouped(
         )
     ).sort("i")
     result = df.with_columns(
-        nw.col("a").cum_max(reverse=reverse).over(_order_by="b")
+        nw.col("a").cum_max(reverse=reverse).over(order_by="b")
     ).sort("i")
     expected = {"a": expected_a, "b": [1, 0, 2], "i": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -219,7 +219,7 @@ def test_lazy_cum_max_ungrouped_ordered_by_nulls(
         )
     ).sort("i")
     result = df.with_columns(
-        nw.col("a").cum_max(reverse=reverse).over(_order_by="b")
+        nw.col("a").cum_max(reverse=reverse).over(order_by="b")
     ).sort("i")
     expected = {
         "a": expected_a,
diff --git a/tests/expr_and_series/cum_min_test.py b/tests/expr_and_series/cum_min_test.py
index da768d79..889f2598 100644
--- a/tests/expr_and_series/cum_min_test.py
+++ b/tests/expr_and_series/cum_min_test.py
@@ -81,7 +81,7 @@ def test_lazy_cum_min_grouped(
         )
     )
     result = df.with_columns(
-        nw.col("a").cum_min(reverse=reverse).over("g", _order_by="b")
+        nw.col("a").cum_min(reverse=reverse).over("g", order_by="b")
     ).sort("i")
     expected = {"a": expected_a, "b": [1, 0, 2], "i": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -129,7 +129,7 @@ def test_lazy_cum_min_ordered_by_nulls(
         )
     )
     result = df.with_columns(
-        nw.col("a").cum_min(reverse=reverse).over("g", _order_by="b")
+        nw.col("a").cum_min(reverse=reverse).over("g", order_by="b")
     ).sort("i")
     expected = {
         "a": expected_a,
@@ -176,7 +176,7 @@ def test_lazy_cum_min_ungrouped(
         )
     ).sort("i")
     result = df.with_columns(
-        nw.col("a").cum_min(reverse=reverse).over(_order_by="b")
+        nw.col("a").cum_min(reverse=reverse).over(order_by="b")
     ).sort("i")
     expected = {"a": expected_a, "b": [1, 0, 2], "i": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -219,7 +219,7 @@ def test_lazy_cum_min_ungrouped_ordered_by_nulls(
         )
     ).sort("i")
     result = df.with_columns(
-        nw.col("a").cum_min(reverse=reverse).over(_order_by="b")
+        nw.col("a").cum_min(reverse=reverse).over(order_by="b")
     ).sort("i")
     expected = {
         "a": expected_a,
diff --git a/tests/expr_and_series/cum_sum_test.py b/tests/expr_and_series/cum_sum_test.py
index ceb674af..63d9a5cc 100644
--- a/tests/expr_and_series/cum_sum_test.py
+++ b/tests/expr_and_series/cum_sum_test.py
@@ -69,7 +69,7 @@ def test_lazy_cum_sum_grouped(
         )
     )
     result = df.with_columns(
-        nw.col("arg entina").cum_sum(reverse=reverse).over("g", _order_by="ban gkock")
+        nw.col("arg entina").cum_sum(reverse=reverse).over("g", order_by="ban gkock")
     ).sort("i ran")
     expected = {"arg entina": expected_a, "ban gkock": [1, 0, 2], "i ran": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -117,7 +117,7 @@ def test_lazy_cum_sum_ordered_by_nulls(
         )
     )
     result = df.with_columns(
-        nw.col("arg entina").cum_sum(reverse=reverse).over("g", _order_by="ban gkock")
+        nw.col("arg entina").cum_sum(reverse=reverse).over("g", order_by="ban gkock")
     ).sort("i ran")
     expected = {
         "arg entina": expected_a,
@@ -162,7 +162,7 @@ def test_lazy_cum_sum_ungrouped(
         )
     ).sort("i ran")
     result = df.with_columns(
-        nw.col("arg entina").cum_sum(reverse=reverse).over(_order_by="ban gkock")
+        nw.col("arg entina").cum_sum(reverse=reverse).over(order_by="ban gkock")
     ).sort("i ran")
     expected = {"arg entina": expected_a, "ban gkock": [1, 0, 2], "i ran": [0, 1, 2]}
     assert_equal_data(result, expected)
@@ -203,7 +203,7 @@ def test_lazy_cum_sum_ungrouped_ordered_by_nulls(
         )
     ).sort("i ran")
     result = df.with_columns(
-        nw.col("arg entina").cum_sum(reverse=reverse).over(_order_by="ban gkock")
+        nw.col("arg entina").cum_sum(reverse=reverse).over(order_by="ban gkock")
     ).sort("i ran")
     expected = {
         "arg entina": expected_a,
diff --git a/tests/expr_and_series/diff_test.py b/tests/expr_and_series/diff_test.py
index e95cf2c6..0fee04a5 100644
--- a/tests/expr_and_series/diff_test.py
+++ b/tests/expr_and_series/diff_test.py
@@ -43,7 +43,7 @@ def test_diff_lazy(constructor: Constructor) -> None:
     if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3):
         pytest.skip()
     df = nw.from_native(constructor(data))
-    result = df.with_columns(c_diff=nw.col("c").diff().over(_order_by="i")).filter(
+    result = df.with_columns(c_diff=nw.col("c").diff().over(order_by="i")).filter(
         nw.col("i") > 0
     )
     expected = {
diff --git a/tests/expr_and_series/is_first_distinct_test.py b/tests/expr_and_series/is_first_distinct_test.py
index c53df7ea..d37c8b66 100644
--- a/tests/expr_and_series/is_first_distinct_test.py
+++ b/tests/expr_and_series/is_first_distinct_test.py
@@ -33,7 +33,7 @@ def test_is_first_distinct_expr_lazy(constructor: Constructor) -> None:
     data = {"a": [1, 1, 2, 3, 2], "b": [1, 2, 3, 2, 1], "i": [0, 1, 2, 3, 4]}
     df = nw.from_native(constructor(data))
     result = (
-        df.select(nw.col("a", "b").is_first_distinct().over(_order_by="i"), "i")
+        df.select(nw.col("a", "b").is_first_distinct().over(order_by="i"), "i")
         .sort("i")
         .drop("i")
     )
@@ -56,7 +56,7 @@ def test_is_first_distinct_expr_lazy_grouped(
     data = {"a": [1, 1, 2, 2, 2], "b": [1, 3, 3, 2, 3], "i": [0, 1, 2, 3, 4]}
     df = nw.from_native(constructor(data))
     result = (
-        df.select(nw.col("b").is_first_distinct().over("a", _order_by="i"), "i")
+        df.select(nw.col("b").is_first_distinct().over("a", order_by="i"), "i")
         .sort("i")
         .drop("i")
     )
diff --git a/tests/expr_and_series/is_last_distinct_test.py b/tests/expr_and_series/is_last_distinct_test.py
index 3404f084..cd23ee8d 100644
--- a/tests/expr_and_series/is_last_distinct_test.py
+++ b/tests/expr_and_series/is_last_distinct_test.py
@@ -31,7 +31,7 @@ def test_is_last_distinct_expr_all(constructor_eager: ConstructorEager) -> None:
         pytest.skip(reason="too old version")
     data = {"a": [1, 1, 2, 3, 2], "b": [1, 2, 3, 2, 1], "i": [0, 1, 2, 3, 4]}
     df = nw.from_native(constructor_eager(data))
-    result = df.select(nw.all().is_last_distinct().over(_order_by="i"))
+    result = df.select(nw.all().is_last_distinct().over(order_by="i"))
     expected = {
         "a": [False, True, False, True, True],
         "b": [False, False, True, True, True],
@@ -49,7 +49,7 @@ def test_is_last_distinct_expr_lazy(constructor: Constructor) -> None:
     data = {"a": [1, 1, 2, 2, 2], "b": [1, 2, 2, 2, 1], "i": [0, 1, 2, 3, 4]}
     df = nw.from_native(constructor(data))
     result = (
-        df.select(nw.col("a", "b").is_last_distinct().over(_order_by="i"), "i")
+        df.select(nw.col("a", "b").is_last_distinct().over(order_by="i"), "i")
         .sort("i")
         .drop("i")
     )
@@ -74,7 +74,7 @@ def test_is_last_distinct_expr_lazy_grouped(
     data = {"a": [1, 1, 2, 2, 2], "b": [1, 2, 2, 2, 1], "i": [0, 1, 2, 3, 4]}
     df = nw.from_native(constructor(data))
     result = (
-        df.select(nw.col("b").is_last_distinct().over("a", _order_by="i"), "i")
+        df.select(nw.col("b").is_last_distinct().over("a", order_by="i"), "i")
         .sort("i")
         .drop("i")
     )
@@ -95,7 +95,7 @@ def test_is_last_distinct_expr_lazy_grouped_nulls(
     data = {"a": [1, 1, 2, 2, 2], "b": [1, 2, 2, 2, 1], "i": [None, 1, 2, 3, 4]}
     df = nw.from_native(constructor(data))
     result = (
-        df.select(nw.col("b").is_last_distinct().over("a", _order_by="i"), "i")
+        df.select(nw.col("b").is_last_distinct().over("a", order_by="i"), "i")
         .sort("i")
         .drop("i")
     )
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index 61c53d28..7ff0f4d8 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -428,7 +428,7 @@ def test_over_without_partition_by(
         request.applymarker(pytest.mark.xfail)
     df = nw.from_native(constructor({"a": [1, -1, 2], "i": [0, 2, 1]}))
     result = (
-        df.with_columns(b=nw.col("a").abs().cum_sum().over(_order_by="i"))
+        df.with_columns(b=nw.col("a").abs().cum_sum().over(order_by="i"))
         .sort("i")
         .select("a", "b", "i")
     )
diff --git a/tests/expr_and_series/rolling_sum_test.py b/tests/expr_and_series/rolling_sum_test.py
index da7ea09c..d7a040ee 100644
--- a/tests/expr_and_series/rolling_sum_test.py
+++ b/tests/expr_and_series/rolling_sum_test.py
@@ -97,7 +97,7 @@ def test_rolling_sum_expr_lazy_ungrouped(
         df.with_columns(
             nw.col("a")
             .rolling_sum(window_size, min_samples=min_samples, center=center)
-            .over(_order_by="b")
+            .over(order_by="b")
         )
         .select("a", "i")
         .sort("i")
@@ -155,7 +155,7 @@ def test_rolling_sum_expr_lazy_grouped(
         df.with_columns(
             nw.col("a")
             .rolling_sum(window_size, min_samples=min_samples, center=center)
-            .over("g", _order_by="b")
+            .over("g", order_by="b")
         )
         .sort("i")
         .select("a")
diff --git a/tests/expr_and_series/shift_test.py b/tests/expr_and_series/shift_test.py
index 2bdf4749..489087f2 100644
--- a/tests/expr_and_series/shift_test.py
+++ b/tests/expr_and_series/shift_test.py
@@ -36,7 +36,7 @@ def test_shift_lazy(constructor: Constructor) -> None:
     if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3):
         pytest.skip()
     df = nw.from_native(constructor(data))
-    result = df.with_columns(nw.col("a", "b", "c").shift(2).over(_order_by="i")).filter(
+    result = df.with_columns(nw.col("a", "b", "c").shift(2).over(order_by="i")).filter(
         nw.col("i") > 1
     )
     expected = {
diff --git a/tests/expression_parsing_test.py b/tests/expression_parsing_test.py
index b540a6fa..21a468e6 100644
--- a/tests/expression_parsing_test.py
+++ b/tests/expression_parsing_test.py
@@ -11,9 +11,9 @@ import narwhals as nw
         (nw.col("a"), 0),
         (nw.col("a").mean(), 0),
         (nw.col("a").cum_sum(), 1),
-        (nw.col("a").cum_sum().over(_order_by="id"), 0),
-        ((nw.col("a").cum_sum() + 1).over(_order_by="id"), 1),
-        (nw.col("a").cum_sum().cum_sum().over(_order_by="id"), 1),
+        (nw.col("a").cum_sum().over(order_by="id"), 0),
+        ((nw.col("a").cum_sum() + 1).over(order_by="id"), 1),
+        (nw.col("a").cum_sum().cum_sum().over(order_by="id"), 1),
         (nw.col("a").cum_sum().cum_sum(), 2),
         (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()), 1),
         (nw.sum_horizontal(nw.col("a"), nw.col("a").cum_sum()).over("a"), 1),
diff --git a/tests/frame/join_test.py b/tests/frame/join_test.py
index 8fb2997e..70f28820 100644
--- a/tests/frame/join_test.py
+++ b/tests/frame/join_test.py
@@ -10,6 +10,7 @@ import pytest
 
 import narwhals as nw_main  # use nw_main in some tests for coverage
 import narwhals.stable.v1 as nw
+from narwhals.exceptions import DuplicateError
 from narwhals.utils import Implementation
 from tests.utils import DUCKDB_VERSION
 from tests.utils import PANDAS_VERSION
@@ -17,6 +18,108 @@ from tests.utils import Constructor
 from tests.utils import assert_equal_data
 
 
+@pytest.mark.parametrize(
+    ("df1", "df2", "expected", "on", "left_on", "right_on"),
+    [
+        (
+            {"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35]},
+            {
+                "id": [2, 3, 4],
+                "department": ["HR", "Engineering", "Marketing"],
+                "salary": [50000, 60000, 70000],
+            },
+            {
+                "id": [1, 2, 3, None],
+                "name": ["Alice", "Bob", "Charlie", None],
+                "age": [25, 30, 35, None],
+                "id_right": [None, 2, 3, 4],
+                "department": [None, "HR", "Engineering", "Marketing"],
+                "salary": [None, 50000, 60000, 70000],
+            },
+            None,
+            ["id"],
+            ["id"],
+        ),
+        (
+            {"id": [1, 2, 3], "name": ["Alice", "Bob", "Charlie"], "age": [25, 30, 35]},
+            {
+                "id": [2, 3, 4],
+                "department": ["HR", "Engineering", "Marketing"],
+                "salary": [50000, 60000, 70000],
+            },
+            {
+                "id": [1, 2, 3, None],
+                "name": ["Alice", "Bob", "Charlie", None],
+                "age": [25, 30, 35, None],
+                "id_right": [None, 2, 3, 4],
+                "department": [None, "HR", "Engineering", "Marketing"],
+                "salary": [None, 50000, 60000, 70000],
+            },
+            "id",
+            None,
+            None,
+        ),
+        (
+            {
+                "id": [1, 2, 3, 4],
+                "year": [2020, 2021, 2022, 2023],
+                "value1": [100, 200, 300, 400],
+            },
+            {
+                "id": [2, 3, 4, 5],
+                "year_foo": [2021, 2022, 2023, 2024],
+                "value2": [500, 600, 700, 800],
+            },
+            {
+                "id": [1, 2, 3, 4, None],
+                "year": [2020, 2021, 2022, 2023, None],
+                "value1": [100, 200, 300, 400, None],
+                "id_right": [None, 2, 3, 4, 5],
+                # since year is different, don't apply suffix
+                "year_foo": [None, 2021, 2022, 2023, 2024],
+                "value2": [None, 500, 600, 700, 800],
+            },
+            None,
+            ["id", "year"],
+            ["id", "year_foo"],
+        ),
+    ],
+)
+def test_full_join(
+    df1: dict[str, list[Any]],
+    df2: dict[str, list[Any]],
+    expected: dict[str, list[Any]],
+    on: None | str | list[str],
+    left_on: None | str | list[str],
+    right_on: None | str | list[str],
+    constructor: Constructor,
+) -> None:
+    df_left = nw_main.from_native(constructor(df1))
+    df_right = nw_main.from_native(constructor(df2))
+    result = df_left.join(
+        df_right, on=on, left_on=left_on, right_on=right_on, how="full"
+    ).sort("id", nulls_last=True)
+    assert_equal_data(result, expected)
+
+
+def test_full_join_duplicate(constructor: Constructor) -> None:
+    df1 = {"foo": [1, 2, 3], "val1": [1, 2, 3]}
+    df2 = {"foo": [1, 2, 3], "foo_right": [1, 2, 3]}
+    df_left = nw_main.from_native(constructor(df1)).lazy()
+    df_right = nw_main.from_native(constructor(df2)).lazy()
+
+    # polars throws `NarwhalsError`, everything else should raise `DuplicateError`
+    exceptions = [DuplicateError, nw.exceptions.NarwhalsError]
+
+    if "pyspark" in str(constructor) and "sqlframe" not in str(constructor):
+        from pyspark.errors import AnalysisException
+
+        exceptions.append(AnalysisException)
+
+    with pytest.raises(tuple(exceptions)):
+        df_left.join(df_right, on="foo", how="full").collect()
+
+
 def test_inner_join_two_keys(constructor: Constructor) -> None:
     data = {
         "antananarivo": [1, 3, 2],
@@ -215,7 +318,7 @@ def test_semi_join(
     assert_equal_data(result, expected)
 
 
-@pytest.mark.parametrize("how", ["right", "full"])
+@pytest.mark.parametrize("how", ["right"])
 def test_join_not_implemented(constructor: Constructor, how: str) -> None:
     data = {"antananarivo": [1, 3, 2], "bob": [4, 4, 6], "zor ro": [7.0, 8.0, 9.0]}
     df = nw.from_native(constructor(data))
@@ -223,7 +326,7 @@ def test_join_not_implemented(constructor: Constructor, how: str) -> None:
     with pytest.raises(
         NotImplementedError,
         match=re.escape(
-            f"Only the following join strategies are supported: ('inner', 'left', 'cross', 'anti', 'semi'); found '{how}'."
+            f"Only the following join strategies are supported: ('inner', 'left', 'full', 'cross', 'anti', 'semi'); found '{how}'."
         ),
     ):
         df.join(df, left_on="antananarivo", right_on="antananarivo", how=how)  # type: ignore[arg-type]