Repository: narwhals-dev/narwhals
Pull request #2374 (2025-04-12 00:24:17)

Fix: Align ``Series.hist`` with new Polars behaviour
<!--
# 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)

- [x] 💾 Refactor
- [ ] ✨ Feature
- [x] 🐛 Bug Fix
- [ ] 🔧 Optimization
- [ ] 📝 Documentation
- [ ] ✅ Test
- [ ] 🐳 Other

## Related issues

- Related issue #\<issue number\>
- Closes #\<issue number\>

## Checklist

- [x] Code follows style guide (ruff)
- [x] Tests added
- [ ] Documented the changes

## If you have comments or can explain your changes, please do so below

1. All narwhals hist behavior should follow Polars 1.27
2. Add histogram fastpaths for no data, or singly binned hists for perf and smooth differences across backends  
3. Slim down the hypothesis testing, as spurious errors would pop up due to floating point precision differences for the compute backends.


Patch:
diff --git a/narwhals/_arrow/series.py b/narwhals/_arrow/series.py
index 02510f4a..2fb9a4c1 100644
--- a/narwhals/_arrow/series.py
+++ b/narwhals/_arrow/series.py
@@ -1065,58 +1065,20 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
 
         def _hist_from_bin_count(bin_count: int):  # type: ignore[no-untyped-def] # noqa: ANN202
             d = pc.min_max(self.native)
-            lower, upper = d["min"], d["max"]
-            pa_float = pa.type_for_alias("float")
+            lower, upper = d["min"].as_py(), d["max"].as_py()
             if lower == upper:
-                range_: pa.Scalar[Any] = lit(1.0)
-                mid = lit(0.5)
-                width = pc.divide(range_, lit(bin_count))
-                lower = pc.subtract(lower, mid)
-                upper = pc.add(upper, mid)
-            else:
-                range_ = pc.subtract(upper, lower)
-                width = pc.divide(pc.cast(range_, pa_float), lit(float(bin_count)))
-
-            bin_proportions = pc.divide(pc.subtract(self.native, lower), width)
-            bin_indices = pc.floor(bin_proportions)
-
-            # shift bins so they are right-closed
-            bin_indices = pc.if_else(
-                pc.and_(
-                    pc.equal(bin_indices, bin_proportions),
-                    pc.greater(bin_indices, lit(0)),
-                ),
-                pc.subtract(bin_indices, lit(1)),
-                bin_indices,
-            )
-            possible = pa.Table.from_arrays(
-                [pa.Array.from_pandas(np.arange(bin_count, dtype="int64"))], ["values"]
-            )
-            counts = (  # count bin id occurrences
-                pa.Table.from_arrays(
-                    pc.value_counts(bin_indices).flatten(),
-                    names=["values", "counts"],
-                )
-                # nan values are implicitly dropped in value_counts
-                .filter(~pc.field("values").is_nan())
-                .cast(pa.schema([("values", pa.int64()), ("counts", pa.int64())]))
-                # align bin ids to all possible bin ids (populate in missing bins)
-                .join(possible, keys="values", join_type="right outer")
-                .sort_by("values")
-            )
-            # empty bin intervals should have a 0 count
-            counts_coalesce = cast(
-                "ArrowArray", pc.coalesce(counts.column("counts"), lit(0))
-            )
-            counts = counts.set_column(0, "counts", counts_coalesce)
-
-            # extract left/right side of the intervals
-            bin_left = pc.add(lower, pc.multiply(counts.column("values"), width))
-            bin_right = pc.add(bin_left, width)
-            return counts.column("counts"), bin_right
+                lower -= 0.5
+                upper += 0.5
+            bins = np.linspace(lower, upper, bin_count + 1)
+            return _hist_from_bins(bins)
 
         def _hist_from_bins(bins: Sequence[int | float]):  # type: ignore[no-untyped-def] # noqa: ANN202
             bin_indices = np.searchsorted(bins, self.native, side="left")
+            bin_indices = pc.if_else(  # lowest bin is inclusive
+                pc.equal(self.native, lit(bins[0])), 1, bin_indices
+            )
+
+            # align unique categories and counts appropriately
             obs_cats, obs_counts = np.unique(bin_indices, return_counts=True)
             obj_cats = np.arange(1, len(bins))
             counts = np.zeros_like(obj_cats)
@@ -1125,15 +1087,51 @@ class ArrowSeries(EagerSeries["ArrowChunkedArray"]):
             bin_right = bins[1:]
             return counts, bin_right
 
+        counts: Sequence[int | float] | np.typing.ArrayLike
+        bin_right: Sequence[int | float] | np.typing.ArrayLike
+
+        data_count = pc.sum(
+            pc.invert(pc.or_(pc.is_nan(self.native), pc.is_null(self.native))).cast(
+                pa.uint8()
+            ),
+            min_count=0,
+        )
         if bins is not None:
             if len(bins) < 2:
                 counts, bin_right = [], []
+
+            elif data_count == pa.scalar(0, type=pa.uint64()):  # type:ignore[comparison-overlap]
+                counts = np.zeros(len(bins) - 1)
+                bin_right = bins[1:]
+
+            elif len(bins) == 2:
+                counts = [
+                    pc.sum(
+                        pc.and_(
+                            pc.greater_equal(self.native, lit(float(bins[0]))),
+                            pc.less_equal(self.native, lit(float(bins[1]))),
+                        ).cast(pa.uint8())
+                    )
+                ]
+                bin_right = [bins[-1]]
             else:
                 counts, bin_right = _hist_from_bins(bins)
 
         elif bin_count is not None:
             if bin_count == 0:
                 counts, bin_right = [], []
+            elif data_count == pa.scalar(0, type=pa.uint64()):  # type:ignore[comparison-overlap]
+                counts, bin_right = (
+                    np.zeros(bin_count),
+                    np.linspace(0, 1, bin_count + 1)[1:],
+                )
+            elif bin_count == 1:
+                d = pc.min_max(self.native)
+                lower, upper = d["min"], d["max"]
+                if lower == upper:
+                    counts, bin_right = [data_count], [pc.add(upper, pa.scalar(0.5))]
+                else:
+                    counts, bin_right = [data_count], [upper]
             else:
                 counts, bin_right = _hist_from_bin_count(bin_count)
 
diff --git a/narwhals/_dask/expr.py b/narwhals/_dask/expr.py
index 6fa8e7e3..a1f0abe4 100644
--- a/narwhals/_dask/expr.py
+++ b/narwhals/_dask/expr.py
@@ -644,9 +644,18 @@ class DaskExpr(
                         message=".*`meta` is not specified",
                         category=UserWarning,
                     )
-                    res_native = df.native.groupby(partition_by)[
-                        list(output_names)
-                    ].transform(dask_function_name, **self._call_kwargs)
+                    grouped = df.native.groupby(partition_by)
+                    if dask_function_name == "size":
+                        if len(output_names) != 1:  # pragma: no cover
+                            msg = "Safety check failed, please report a bug."
+                            raise AssertionError(msg)
+                        res_native = grouped.transform(
+                            dask_function_name, **self._call_kwargs
+                        ).to_frame(output_names[0])
+                    else:
+                        res_native = grouped[list(output_names)].transform(
+                            dask_function_name, **self._call_kwargs
+                        )
                 result_frame = df._with_native(
                     res_native.rename(columns=dict(zip(output_names, aliases)))
                 ).native
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index 0f4584a5..a812a386 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -198,7 +198,7 @@ class PandasLikeExpr(EagerExpr["PandasLikeDataFrame", PandasLikeSeries]):
     def shift(self: Self, n: int) -> Self:
         return self._reuse_series("shift", call_kwargs={"n": n})
 
-    def over(
+    def over(  # noqa: PLR0915
         self: Self,
         partition_by: Sequence[str],
         order_by: Sequence[str] | None,
@@ -265,10 +265,9 @@ class PandasLikeExpr(EagerExpr["PandasLikeDataFrame", PandasLikeSeries]):
                 elif reverse:
                     columns = list(set(partition_by).union(output_names))
                     df = df[columns][::-1]
+                grouped = df._native_frame.groupby(partition_by)
                 if function_name.startswith("rolling"):
-                    rolling = df._native_frame.groupby(partition_by)[
-                        list(output_names)
-                    ].rolling(**pandas_kwargs)
+                    rolling = grouped[list(output_names)].rolling(**pandas_kwargs)
                     assert pandas_function_name is not None  # help mypy  # noqa: S101
                     if pandas_function_name in {"std", "var"}:
                         res_native = getattr(rolling, pandas_function_name)(
@@ -276,10 +275,15 @@ class PandasLikeExpr(EagerExpr["PandasLikeDataFrame", PandasLikeSeries]):
                         )
                     else:
                         res_native = getattr(rolling, pandas_function_name)()
+                elif function_name == "len":
+                    if len(output_names) != 1:  # pragma: no cover
+                        msg = "Safety check failed, please report a bug."
+                        raise AssertionError(msg)
+                    res_native = grouped.transform("size").to_frame(aliases[0])
                 else:
-                    res_native = df._native_frame.groupby(partition_by)[
-                        list(output_names)
-                    ].transform(pandas_function_name, **pandas_kwargs)
+                    res_native = grouped[list(output_names)].transform(
+                        pandas_function_name, **pandas_kwargs
+                    )
                 result_frame = df._with_native(res_native).rename(
                     dict(zip(output_names, aliases))
                 )
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index cdd304d2..fa280ecc 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -966,34 +966,50 @@ class PandasLikeSeries(EagerSeries[Any]):
                 data["breakpoint"] = []
             data["count"] = []
             return PandasLikeDataFrame.from_native(ns.DataFrame(data), context=self)
-        elif self.native.count() < 1:
+
+        if self.native.count() < 1:
             if bins is not None:
                 data = {"breakpoint": bins[1:], "count": zeros(shape=len(bins) - 1)}
             else:
                 count = cast("int", bin_count)
-                data = {"breakpoint": linspace(0, 1, count), "count": zeros(shape=count)}
+                if bin_count == 1:
+                    data = {"breakpoint": [1.0], "count": [0]}
+                else:
+                    data = {
+                        "breakpoint": linspace(0, 1, count + 1)[1:],
+                        "count": zeros(shape=count),
+                    }
             if not include_breakpoint:
                 del data["breakpoint"]
             return PandasLikeDataFrame.from_native(ns.DataFrame(data), context=self)
 
-        elif bin_count is not None:  # use Polars binning behavior
+        if bin_count is not None:
+            # use Polars binning behavior
             lower, upper = self.native.min(), self.native.max()
-            pad_lowest_bin = False
             if lower == upper:
                 lower -= 0.5
                 upper += 0.5
-            else:
-                pad_lowest_bin = True
+
+            if bin_count == 1:
+                data = {
+                    "breakpoint": [upper],
+                    "count": [self.native.count()],
+                }
+                if not include_breakpoint:
+                    del data["breakpoint"]
+                return PandasLikeDataFrame.from_native(ns.DataFrame(data), context=self)
 
             bins = linspace(lower, upper, bin_count + 1)
-            if pad_lowest_bin and bins is not None:
-                bins[0] -= 0.001 * abs(bins[0]) if bins[0] != 0 else 0.001
             bin_count = None
 
         # pandas (2.2.*) .value_counts(bins=int) adjusts the lowest bin twice, result in improper counts.
         # pandas (2.2.*) .value_counts(bins=[...]) adjusts the lowest bin which should not happen since
         #   the bins were explicitly passed in.
-        categories = ns.cut(self.native, bins=bins if bin_count is None else bin_count)
+        categories = ns.cut(
+            self.native,
+            bins=bins if bin_count is None else bin_count,
+            include_lowest=True,  # Polars 1.27.0 always includes the lowest bin
+        )
         # modin (0.32.0) .value_counts(...) silently drops bins with empty observations, .reindex
         #   is necessary to restore these bins.
         result = categories.value_counts(dropna=True, sort=False).reindex(
diff --git a/narwhals/_polars/series.py b/narwhals/_polars/series.py
index f0b7eae1..ca9c0410 100644
--- a/narwhals/_polars/series.py
+++ b/narwhals/_polars/series.py
@@ -499,18 +499,26 @@ class PolarsSeries:
                 data.append(pl.Series("breakpoint", [], dtype=pl.Float64))
             data.append(pl.Series("count", [], dtype=pl.UInt32))
             return PolarsDataFrame.from_native(pl.DataFrame(data), context=self)
-        elif (self._backend_version < (1, 15)) and self.native.count() < 1:
+
+        if self.native.count() < 1:
             data_dict: dict[str, Sequence[Any] | pl.Series]
             if bins is not None:
                 data_dict = {
                     "breakpoint": bins[1:],
                     "count": pl.zeros(n=len(bins) - 1, dtype=pl.Int64, eager=True),
                 }
-            elif bin_count is not None:
+            elif (bin_count is not None) and bin_count == 1:
+                data_dict = {"breakpoint": [1.0], "count": [0]}
+            elif (bin_count is not None) and bin_count > 1:
                 data_dict = {
-                    "breakpoint": pl.int_range(0, bin_count, eager=True) / bin_count,
+                    "breakpoint": pl.int_range(1, bin_count + 1, eager=True) / bin_count,
                     "count": pl.zeros(n=bin_count, dtype=pl.Int64, eager=True),
                 }
+            else:  # pragma: no cover
+                msg = (
+                    "congratulations, you entered unreachable code - please report a bug"
+                )
+                raise AssertionError(msg)
             if not include_breakpoint:
                 del data_dict["breakpoint"]
             return PolarsDataFrame.from_native(pl.DataFrame(data_dict), context=self)
@@ -519,25 +527,19 @@ class PolarsSeries:
         # polars <1.5 with bin_count=...
         # returns bins that range from -inf to +inf and has bin_count + 1 bins.
         #   for compat: convert `bin_count=` call to `bins=`
-        if (
-            (self._backend_version < (1, 15))
-            and (bin_count is not None)
-            and (self.native.count() > 0)
+        if (self._backend_version < (1, 15)) and (
+            bin_count is not None
         ):  # pragma: no cover
             lower = cast("float", self.native.min())
             upper = cast("float", self.native.max())
-            pad_lowest_bin = False
             if lower == upper:
                 width = 1 / bin_count
                 lower -= 0.5
                 upper += 0.5
             else:
-                pad_lowest_bin = True
                 width = (upper - lower) / bin_count
 
             bins = (pl.int_range(0, bin_count + 1, eager=True) * width + lower).to_list()
-            if pad_lowest_bin:
-                bins[0] -= 0.001 * abs(bins[0]) if bins[0] != 0 else 0.001
             bin_count = None
 
         # Polars inconsistently handles NaN values when computing histograms
@@ -552,16 +554,22 @@ class PolarsSeries:
             include_category=False,
             include_breakpoint=include_breakpoint,
         )
+
         if not include_breakpoint:
             df.columns = ["count"]
 
+        if self._backend_version < (1, 0) and include_breakpoint:
+            df = df.rename({"break_point": "breakpoint"})
+
         #  polars<1.15 implicitly adds -inf and inf to either end of bins
         if self._backend_version < (1, 15) and bins is not None:  # pragma: no cover
             r = pl.int_range(0, len(df))
             df = df.filter((r > 0) & (r < len(df) - 1))
 
-        if self._backend_version < (1, 0) and include_breakpoint:
-            df = df.rename({"break_point": "breakpoint"})
+        # polars<1.27 makes the lowest bin a left/right closed interval.
+        if self._backend_version < (1, 27) and bins is not None:
+            df[0, "count"] += (series == bins[0]).sum()
+
         return PolarsDataFrame.from_native(df, context=self)
 
     def to_polars(self: Self) -> pl.Series:


Test patch:
diff --git a/tests/expr_and_series/over_test.py b/tests/expr_and_series/over_test.py
index be800852..2e53ab1d 100644
--- a/tests/expr_and_series/over_test.py
+++ b/tests/expr_and_series/over_test.py
@@ -428,3 +428,17 @@ def test_over_without_partition_by(
     )
     expected = {"a": [1, 2, -1], "b": [1, 3, 4], "i": [0, 1, 2]}
     assert_equal_data(result, expected)
+
+
+def test_len_over_2369(constructor: Constructor, request: pytest.FixtureRequest) -> None:
+    if "duckdb" in str(constructor) and DUCKDB_VERSION < (1, 3):
+        pytest.skip()
+    if "pandas" in str(constructor) and PANDAS_VERSION < (1, 5):
+        pytest.skip()
+    if any(x in str(constructor) for x in ("modin",)):
+        # https://github.com/modin-project/modin/issues/7508
+        request.applymarker(pytest.mark.xfail)
+    df = nw.from_native(constructor({"a": [1, 2, 4], "b": ["x", "x", "y"]}))
+    result = df.with_columns(a_len_per_group=nw.len().over("b")).sort("a")
+    expected = {"a": [1, 2, 4], "b": ["x", "x", "y"], "a_len_per_group": [2, 2, 1]}
+    assert_equal_data(result, expected)
diff --git a/tests/series_only/hist_test.py b/tests/series_only/hist_test.py
index 8d8b30ad..5f9cb3ec 100644
--- a/tests/series_only/hist_test.py
+++ b/tests/series_only/hist_test.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+from random import Random
 from typing import Any
 
 import hypothesis.strategies as st
@@ -13,13 +14,13 @@ from tests.utils import PYARROW_VERSION
 from tests.utils import ConstructorEager
 from tests.utils import assert_equal_data
 
-xfail_hist = pytest.mark.xfail(
-    reason="https://github.com/narwhals-dev/narwhals/issues/2348", strict=False
-)
-
+rnd = Random(0)  # noqa: S311
 
-data = {
+data: dict[str, list[int | float]] = {
     "int": [0, 1, 2, 3, 4, 5, 6],
+    "float": [0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0],
+    "int_shuffled": [1, 0, 2, 3, 6, 5, 4],
+    "float_shuffled": [1.0, 0.0, 2.0, 3.0, 6.0, 5.0, 4.0],
 }
 
 bins_and_expected = [
@@ -29,11 +30,11 @@ bins_and_expected = [
     },
     {
         "bins": [1.0, 2.5, 5.5, float("inf")],
-        "expected": [1, 3, 1],
+        "expected": [2, 3, 1],
     },
     {
         "bins": [1.0, 2.5, 5.5],
-        "expected": [1, 3],
+        "expected": [2, 3],
     },
     {
         "bins": [-10.0, -1.0, 2.5, 5.5],
@@ -41,23 +42,27 @@ bins_and_expected = [
     },
     {
         "bins": [1.0, 2.0625],
-        "expected": [1],
+        "expected": [2],
     },
     {
         "bins": [1],
         "expected": [],
     },
+    {
+        "bins": [0, 10],
+        "expected": [7],
+    },
 ]
 counts_and_expected = [
     {
         "bin_count": 4,
-        "expected_bins": [-0.006, 1.5, 3.0, 4.5, 6.0],
+        "expected_bins": [0, 1.5, 3.0, 4.5, 6.0],
         "expected_count": [2, 2, 1, 2],
     },
     {
         "bin_count": 12,
         "expected_bins": [
-            -0.006,
+            0,
             0.5,
             1.0,
             1.5,
@@ -73,6 +78,11 @@ counts_and_expected = [
         ],
         "expected_count": [1, 1, 0, 1, 0, 1, 0, 1, 0, 1, 0, 1],
     },
+    {
+        "bin_count": 1,
+        "expected_bins": [0, 6],
+        "expected_count": [7],
+    },
     {
         "bin_count": 0,
         "expected_bins": [],
@@ -81,7 +91,6 @@ counts_and_expected = [
 ]
 
 
-@xfail_hist
 @pytest.mark.parametrize("params", bins_and_expected)
 @pytest.mark.parametrize("include_breakpoint", [True, False])
 @pytest.mark.filterwarnings(
@@ -121,8 +130,7 @@ def test_hist_bin(
         assert_equal_data(result, expected)
 
         # result size property
-        if len(bins) < 2:
-            assert len(result) == 0
+        assert len(result) == max(len(bins) - 1, 0)
 
     # shift bins property
     shift_by = 10
@@ -167,7 +175,6 @@ def test_hist_bin(
         assert_equal_data(result, expected)
 
 
-@xfail_hist
 @pytest.mark.parametrize("params", counts_and_expected)
 @pytest.mark.parametrize("include_breakpoint", [True, False])
 @pytest.mark.filterwarnings(
@@ -208,9 +215,9 @@ def test_hist_count(
         assert_equal_data(result, expected)
 
         # result size property
-        if bin_count < 2:
-            assert len(result) == 0
-        else:
+
+        assert len(result) == bin_count
+        if bin_count > 0:
             assert result["count"].sum() == df[col].count()
 
     # missing/nan results
@@ -231,15 +238,13 @@ def test_hist_count(
         assert_equal_data(result, expected)
 
         # result size property
-        if bin_count < 2:
-            assert len(result) == 0
-        else:
+        assert len(result) == bin_count
+        if bin_count > 0:
             assert (
                 result["count"].sum() == (~(df[col].is_nan() | df[col].is_null())).sum()
             )
 
 
-@xfail_hist
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
 )
@@ -276,7 +281,6 @@ def test_hist_count_no_spread(
     assert_equal_data(result, expected)
 
 
-@xfail_hist
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
 )
@@ -292,7 +296,7 @@ def test_hist_bin_and_bin_count() -> None:
         s.hist(bins=[1, 3], bin_count=4)
 
 
-@xfail_hist
+@pytest.mark.parametrize("include_breakpoint", [True, False])
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
 )
@@ -300,6 +304,7 @@ def test_hist_no_data(
     constructor_eager: ConstructorEager,
     *,
     request: pytest.FixtureRequest,
+    include_breakpoint: bool,
 ) -> None:
     if "cudf" in str(constructor_eager):
         # TODO(unassigned): too many spurious failures, report and revisit
@@ -309,15 +314,20 @@ def test_hist_no_data(
     s = nw.from_native(constructor_eager({"values": []})).select(
         nw.col("values").cast(nw.Float64)
     )["values"]
-    for include_breakpoint in [True, False]:
-        result = s.hist(bin_count=10, include_breakpoint=include_breakpoint)
-        assert len(result) == 10
+    for bin_count in [1, 10]:
+        result = s.hist(bin_count=bin_count, include_breakpoint=include_breakpoint)
+        assert len(result) == bin_count
         assert result["count"].sum() == 0
 
-    for include_breakpoint in [True, False]:
-        result = s.hist(bins=[1, 5, 10], include_breakpoint=include_breakpoint)
-        assert len(result) == 2
-        assert result["count"].sum() == 0
+        if include_breakpoint:
+            bps = result["breakpoint"].to_list()
+            assert bps[0] == (1 / bin_count)
+            if bin_count > 1:
+                assert bps[-1] == 1
+
+    result = s.hist(bins=[1, 5, 10], include_breakpoint=include_breakpoint)
+    assert len(result) == 2
+    assert result["count"].sum() == 0
 
 
 @pytest.mark.filterwarnings(
@@ -341,7 +351,6 @@ def test_hist_small_bins(
         s["values"].hist(bins=[1, 3], bin_count=4)
 
 
-@xfail_hist
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature."
 )
@@ -362,12 +371,16 @@ def test_hist_non_monotonic(constructor_eager: ConstructorEager) -> None:
     data=st.lists(
         # Bug in Polars <= 1.21; computing histograms with NaN data and passed bins can be unreliable
         #   this leads to flaky hypothesis testing https://github.com/pola-rs/polars/issues/21082
-        # min_value/max_value from PyArrows min/max boundaries
-        st.floats(
-            min_value=-9007199254740992,
-            max_value=9007199254740992,
-            allow_nan=False,
-            allow_infinity=False,
+        # min_value/max_value; avoid issues with floating point value imprecision
+        # small floats around 0 are incorrectly binned by Polars
+        st.one_of(
+            st.just(0.0),
+            st.floats(
+                min_value=-1e10, max_value=-1e-4, allow_nan=False, allow_subnormal=False
+            ),
+            st.floats(
+                min_value=1e-4, max_value=1e10, allow_nan=False, allow_subnormal=False
+            ),
         ),
         min_size=0,
         max_size=100,
@@ -376,14 +389,13 @@ def test_hist_non_monotonic(constructor_eager: ConstructorEager) -> None:
         st.floats(min_value=0.001, max_value=1_000, allow_nan=False), max_size=50
     ),
 )
-@xfail_hist
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature.",
     "ignore:invalid value encountered in cast:RuntimeWarning",
 )
 @pytest.mark.skipif(
-    POLARS_VERSION < (1, 15),
-    reason="hist(bins=...) cannot be used for compatibility checks since narwhals aims to mimic polars>=1.2.0 behavior",
+    POLARS_VERSION < (1, 27),
+    reason="polars cannot be used for compatibility checks since narwhals aims to mimic polars>=1.27 behavior",
 )
 @pytest.mark.slow
 def test_hist_bin_hypotheis(
@@ -421,22 +433,16 @@ def test_hist_bin_hypotheis(
 
 @given(
     data=st.lists(
-        # min_value/max_value from PyArrows min/max boundaries
-        st.floats(
-            min_value=-9007199254740992,
-            max_value=9007199254740992,
-            allow_nan=False,
-            allow_infinity=False,
-        ),
-        min_size=0,
+        st.integers(
+            min_value=-int(1e9), max_value=int(1e9)
+        ),  # dynamic bin creation & small floating point value distances creates lots of noise
         max_size=100,
     ),
-    bin_count=st.integers(min_value=0, max_value=1_000),
+    bin_count=st.integers(min_value=1, max_value=20),
 )
-@xfail_hist
 @pytest.mark.skipif(
-    POLARS_VERSION < (1, 15),
-    reason="hist(bin_count=...) behavior significantly changed after this version",
+    POLARS_VERSION < (1, 27),
+    reason="polars cannot be used for compatibility checks since narwhals aims to mimic polars>=1.27 behavior",
 )
 @pytest.mark.filterwarnings(
     "ignore:`Series.hist` is being called from the stable API although considered an unstable feature.",
@@ -480,7 +486,10 @@ def test_hist_count_hypothesis(
     # Bug in Polars <= 1.21; hist becomes unreliable when passing bin_counts
     #   for data with a wide range and a large number of passed bins
     #   https://github.com/pola-rs/polars/issues/20879
-    if expected["count"].sum() != expected_data.count() and "polars" not in str(
+
+    if expected[
+        "count"
+    ].sum() != expected_data.is_not_nan().sum() and "polars" not in str(
         constructor_eager
     ):
         request.applymarker(pytest.mark.xfail)