Repository: narwhals-dev/narwhals

Issue:
Add Expr.quantile and Series.quantile
https://docs.pola.rs/py-polars/html/reference/expressions/api/polars.Expr.quantile.html#polars.Expr.quantile

I'm _slightly_ worried that the default interpolation method might change in Polars at some point, so we should probably not put any default for it in Narwhals and force users to specify it explicitly 

Discussion:
FBruzzesi: To check the pandas behavior in the [failing GHA](https://github.com/narwhals-dev/narwhals/actions/runs/9224134064/job/25378803025?pr=207), I found that the min version in which it doesn't break is `pandas==1.3.5`. Pandas versions previous to that one require `numpy<=1.21`.
MarcoGorelli: thanks - maybe we can just pin `numpy` in the ci-minimum-versions CI job too then?

Patch:
diff --git a/.github/workflows/extremes.yml b/.github/workflows/extremes.yml
index 884cb785..771ed16f 100644
--- a/.github/workflows/extremes.yml
+++ b/.github/workflows/extremes.yml
@@ -29,7 +29,7 @@ jobs:
       - name: install-reqs
         run: python -m pip install --upgrade tox virtualenv setuptools pip -r requirements-dev.txt
       - name: install-modin
-        run: python -m pip install pandas==1.1.5 polars==0.20.3
+        run: python -m pip install pandas==1.1.5 polars==0.20.3 "numpy<=1.21"
       - name: Run pytest
         run: pytest tests --cov=narwhals --cov=tests --cov-fail-under=50 --runslow
       - name: Run doctests
diff --git a/README.md b/README.md
index 0172a847..4495baf5 100644
--- a/README.md
+++ b/README.md
@@ -20,9 +20,9 @@ Seamlessly support all, without depending on any!
 - ✅ **No dependencies** (not even Polars), keep your library lightweight
 - ✅ Separate **lazy** and eager APIs
 - ✅ Use **Expressions**
-- ✅ 100% branch coverage, tested against pandas and Polars nightly builds!
-- ✅ Preserve your Index (if present) without it getting in the way!
-- ✅ **Zero 3rd party imports**, Narwhals only uses what you already have!
+- ✅ 100% branch coverage, tested against pandas and Polars nightly builds
+- ✅ Preserve your Index (if present) without it getting in the way
+- ✅ **Zero 3rd party imports**, Narwhals only uses what you already have
 
 ## Used by
 
@@ -58,7 +58,7 @@ There are three steps to writing dataframe-agnostic code using Narwhals:
 
 Like Ibis, Narwhals aims to enable dataframe-agnostic code. However, Narwhals comes with **zero** dependencies,
 is about as lightweight as it gets, and is aimed at library developers rather than at end users. It also does
-not aim to support as many backends, preferring to instead focus on dataframes. So, which should you use?
+not aim to support as many backends, instead preferring to focus on dataframes. So, which should you use?
 
 - If you need to run complicated analyses and aren't too bothered about package size: Ibis!
 - If you're a library maintainer and want the thinnest-possible layer to get cross-dataframe library support: Narwhals!
@@ -75,9 +75,9 @@ See the [tutorial](https://narwhals-dev.github.io/narwhals/basics/dataframe/) fo
 ## Scope
 
 - Do you maintain a dataframe-consuming library?
-- Is there a Polars function which you'd like Narwhals to have, which would make your work easier?
+- Do you have a specific Polars function in mind that you would like Narwhals to have in order to make your work easier?
 
-If, I'd love to hear from you!
+If you said yes to both, we'd love to hear from you!
 
 **Note**: You might suspect that this is a secret ploy to infiltrate the Polars API everywhere.
 Indeed, you may suspect that.
diff --git a/docs/api-reference/expressions.md b/docs/api-reference/expressions.md
index a889d5b7..bf2aee98 100644
--- a/docs/api-reference/expressions.md
+++ b/docs/api-reference/expressions.md
@@ -26,11 +26,12 @@
         - null_count
         - n_unique
         - over
-        - unique
+        - quantile
         - sample
         - shift
         - sort
         - std
         - sum
+        - unique
       show_source: false
       show_bases: false
diff --git a/docs/api-reference/series.md b/docs/api-reference/series.md
index e5ab96e7..3db3fdbf 100644
--- a/docs/api-reference/series.md
+++ b/docs/api-reference/series.md
@@ -29,6 +29,7 @@
         - name
         - null_count
         - n_unique
+        - quantile
         - sample
         - shape
         - shift
diff --git a/docs/how_it_works.md b/docs/how_it_works.md
index e5e9fa4e..e6a46371 100644
--- a/docs/how_it_works.md
+++ b/docs/how_it_works.md
@@ -128,7 +128,7 @@ Name: a, dtype: int64
 So indeed, our expression did what it said on the tin - it took some dataframe, took
 column 'a', and added 1 to it.
 
-If you search for `def register_expression_call`, you'll see that that's all
+If you search for `def reuse_series_implementation`, you'll see that that's all
 expressions do in Narwhals - they just keep rigorously applying the definition of
 expression.
 
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 8910977c..824c1d10 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -19,12 +19,6 @@ Currently, it can execute the first 7.
 Narwhals is extesible - can we make it as easy as possible for backends to become
 compatible with it?
 
-## Query optimisation
-
-Can we insert a light layer to do some simple query optimisation for pandas?
-
 ## Use cases
 
-We're currently investigating whether we can make scikit-lego dataframe-agnostic.
-
-Ideas for other projects we could support? If so, please post them [here](https://github.com/narwhals-dev/narwhals/issues/62).
+Ideas for other projects where Narwhals might be useful? If so, please post them [here](https://github.com/narwhals-dev/narwhals/issues/62).
diff --git a/narwhals/_pandas_like/expr.py b/narwhals/_pandas_like/expr.py
index a3e96075..ec505cbf 100644
--- a/narwhals/_pandas_like/expr.py
+++ b/narwhals/_pandas_like/expr.py
@@ -3,10 +3,11 @@ from __future__ import annotations
 from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
+from typing import Literal
 
 from narwhals._pandas_like.series import PandasSeries
-from narwhals._pandas_like.utils import register_expression_call
-from narwhals._pandas_like.utils import register_namespace_expression_call
+from narwhals._pandas_like.utils import reuse_series_implementation
+from narwhals._pandas_like.utils import reuse_series_namespace_implementation
 
 if TYPE_CHECKING:
     from typing_extensions import Self
@@ -46,14 +47,17 @@ class PandasExpr:
     def from_column_names(
         cls: type[Self], *column_names: str, implementation: str
     ) -> Self:
-        return cls(
-            lambda df: [
+        def func(df: PandasDataFrame) -> list[PandasSeries]:
+            return [
                 PandasSeries(
                     df._dataframe.loc[:, column_name],
                     implementation=df._implementation,
                 )
                 for column_name in column_names
-            ],
+            ]
+
+        return cls(
+            func,
             depth=0,
             function_name="col",
             root_names=list(column_names),
@@ -65,152 +69,158 @@ class PandasExpr:
         self,
         dtype: Any,
     ) -> Self:
-        return register_expression_call(self, "cast", dtype)
+        return reuse_series_implementation(self, "cast", dtype=dtype)
 
     def __eq__(self, other: PandasExpr | Any) -> Self:  # type: ignore[override]
-        return register_expression_call(self, "__eq__", other)
+        return reuse_series_implementation(self, "__eq__", other=other)
 
     def __ne__(self, other: PandasExpr | Any) -> Self:  # type: ignore[override]
-        return register_expression_call(self, "__ne__", other)
+        return reuse_series_implementation(self, "__ne__", other=other)
 
     def __ge__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__ge__", other)
+        return reuse_series_implementation(self, "__ge__", other=other)
 
     def __gt__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__gt__", other)
+        return reuse_series_implementation(self, "__gt__", other=other)
 
     def __le__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__le__", other)
+        return reuse_series_implementation(self, "__le__", other=other)
 
     def __lt__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__lt__", other)
+        return reuse_series_implementation(self, "__lt__", other=other)
 
     def __and__(self, other: PandasExpr | bool | Any) -> Self:
-        return register_expression_call(self, "__and__", other)
+        return reuse_series_implementation(self, "__and__", other=other)
 
     def __rand__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rand__", other)
+        return reuse_series_implementation(self, "__rand__", other=other)
 
     def __or__(self, other: PandasExpr | bool | Any) -> Self:
-        return register_expression_call(self, "__or__", other)
+        return reuse_series_implementation(self, "__or__", other=other)
 
     def __ror__(self, other: Any) -> Self:
-        return register_expression_call(self, "__ror__", other)
+        return reuse_series_implementation(self, "__ror__", other=other)
 
     def __add__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__add__", other)
+        return reuse_series_implementation(self, "__add__", other=other)
 
     def __radd__(self, other: Any) -> Self:
-        return register_expression_call(self, "__radd__", other)
+        return reuse_series_implementation(self, "__radd__", other=other)
 
     def __sub__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__sub__", other)
+        return reuse_series_implementation(self, "__sub__", other=other)
 
     def __rsub__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rsub__", other)
+        return reuse_series_implementation(self, "__rsub__", other=other)
 
     def __mul__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__mul__", other)
+        return reuse_series_implementation(self, "__mul__", other=other)
 
     def __rmul__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rmul__", other)
+        return reuse_series_implementation(self, "__rmul__", other=other)
 
     def __truediv__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__truediv__", other)
+        return reuse_series_implementation(self, "__truediv__", other=other)
 
     def __rtruediv__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rtruediv__", other)
+        return reuse_series_implementation(self, "__rtruediv__", other=other)
 
     def __floordiv__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__floordiv__", other)
+        return reuse_series_implementation(self, "__floordiv__", other=other)
 
     def __rfloordiv__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rfloordiv__", other)
+        return reuse_series_implementation(self, "__rfloordiv__", other=other)
 
     def __pow__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__pow__", other)
+        return reuse_series_implementation(self, "__pow__", other=other)
 
     def __rpow__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rpow__", other)
+        return reuse_series_implementation(self, "__rpow__", other=other)
 
     def __mod__(self, other: PandasExpr | Any) -> Self:
-        return register_expression_call(self, "__mod__", other)
+        return reuse_series_implementation(self, "__mod__", other=other)
 
     def __rmod__(self, other: Any) -> Self:
-        return register_expression_call(self, "__rmod__", other)
+        return reuse_series_implementation(self, "__rmod__", other=other)
 
     # Unary
 
     def __invert__(self) -> Self:
-        return register_expression_call(self, "__invert__")
+        return reuse_series_implementation(self, "__invert__")
 
     # Reductions
+    def null_count(self) -> Self:
+        return reuse_series_implementation(self, "null_count", returns_scalar=True)
+
+    def n_unique(self) -> Self:
+        return reuse_series_implementation(self, "n_unique", returns_scalar=True)
 
     def sum(self) -> Self:
-        return register_expression_call(self, "sum")
+        return reuse_series_implementation(self, "sum", returns_scalar=True)
 
     def mean(self) -> Self:
-        return register_expression_call(self, "mean")
+        return reuse_series_implementation(self, "mean", returns_scalar=True)
 
     def std(self, *, ddof: int = 1) -> Self:
-        return register_expression_call(self, "std", ddof=ddof)
+        return reuse_series_implementation(self, "std", ddof=ddof, returns_scalar=True)
 
     def any(self) -> Self:
-        return register_expression_call(self, "any")
+        return reuse_series_implementation(self, "any", returns_scalar=True)
 
     def all(self) -> Self:
-        return register_expression_call(self, "all")
+        return reuse_series_implementation(self, "all", returns_scalar=True)
 
     def max(self) -> Self:
-        return register_expression_call(self, "max")
+        return reuse_series_implementation(self, "max", returns_scalar=True)
 
     def min(self) -> Self:
-        return register_expression_call(self, "min")
+        return reuse_series_implementation(self, "min", returns_scalar=True)
 
     # Other
     def is_between(
         self, lower_bound: Any, upper_bound: Any, closed: str = "both"
     ) -> Self:
-        return register_expression_call(
-            self, "is_between", lower_bound, upper_bound, closed
+        return reuse_series_implementation(
+            self,
+            "is_between",
+            lower_bound=lower_bound,
+            upper_bound=upper_bound,
+            closed=closed,
         )
 
     def is_null(self) -> Self:
-        return register_expression_call(self, "is_null")
+        return reuse_series_implementation(self, "is_null")
 
     def fill_null(self, value: Any) -> Self:
-        return register_expression_call(self, "fill_null", value)
+        return reuse_series_implementation(self, "fill_null", value=value)
 
     def is_in(self, other: Any) -> Self:
-        return register_expression_call(self, "is_in", other)
+        return reuse_series_implementation(self, "is_in", other=other)
 
     def filter(self, *predicates: Any) -> Self:
         from narwhals._pandas_like.namespace import PandasNamespace
 
         plx = PandasNamespace(self._implementation)
         expr = plx.all_horizontal(*predicates)
-        return register_expression_call(self, "filter", expr)
+        return reuse_series_implementation(self, "filter", other=expr)
 
     def drop_nulls(self) -> Self:
-        return register_expression_call(self, "drop_nulls")
+        return reuse_series_implementation(self, "drop_nulls")
 
     def sort(self, *, descending: bool = False) -> Self:
-        return register_expression_call(self, "sort", descending=descending)
-
-    def n_unique(self) -> Self:
-        return register_expression_call(self, "n_unique")
+        return reuse_series_implementation(self, "sort", descending=descending)
 
     def cum_sum(self) -> Self:
-        return register_expression_call(self, "cum_sum")
+        return reuse_series_implementation(self, "cum_sum")
 
     def unique(self) -> Self:
-        return register_expression_call(self, "unique")
+        return reuse_series_implementation(self, "unique")
 
     def diff(self) -> Self:
-        return register_expression_call(self, "diff")
+        return reuse_series_implementation(self, "diff")
 
     def shift(self, n: int) -> Self:
-        return register_expression_call(self, "shift", n)
+        return reuse_series_implementation(self, "shift", n=n)
 
     def sample(
         self,
@@ -219,8 +229,8 @@ class PandasExpr:
         *,
         with_replacement: bool = False,
     ) -> Self:
-        return register_expression_call(
-            self, "sample", n, fraction=fraction, with_replacement=with_replacement
+        return reuse_series_implementation(
+            self, "sample", n=n, fraction=fraction, with_replacement=with_replacement
         )
 
     def alias(self, name: str) -> Self:
@@ -258,19 +268,23 @@ class PandasExpr:
         )
 
     def is_duplicated(self) -> Self:
-        return register_expression_call(self, "is_duplicated")
+        return reuse_series_implementation(self, "is_duplicated")
 
     def is_unique(self) -> Self:
-        return register_expression_call(self, "is_unique")
-
-    def null_count(self) -> Self:
-        return register_expression_call(self, "null_count")
+        return reuse_series_implementation(self, "is_unique")
 
     def is_first_distinct(self) -> Self:
-        return register_expression_call(self, "is_first_distinct")
+        return reuse_series_implementation(self, "is_first_distinct")
 
     def is_last_distinct(self) -> Self:
-        return register_expression_call(self, "is_last_distinct")
+        return reuse_series_implementation(self, "is_last_distinct")
+
+    def quantile(
+        self,
+        quantile: float,
+        interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    ) -> Self:
+        return register_expression_call(self, "quantile", quantile, interpolation)
 
     @property
     def str(self) -> PandasExprStringNamespace:
@@ -286,7 +300,7 @@ class PandasExprStringNamespace:
         self._expr = expr
 
     def ends_with(self, suffix: str) -> PandasExpr:
-        return register_namespace_expression_call(
+        return reuse_series_namespace_implementation(
             self._expr,
             "str",
             "ends_with",
@@ -294,7 +308,7 @@ class PandasExprStringNamespace:
         )
 
     def head(self, n: int = 5) -> PandasExpr:
-        return register_namespace_expression_call(
+        return reuse_series_namespace_implementation(
             self._expr,
             "str",
             "head",
@@ -302,7 +316,7 @@ class PandasExprStringNamespace:
         )
 
     def to_datetime(self, format: str | None = None) -> PandasExpr:  # noqa: A002
-        return register_namespace_expression_call(
+        return reuse_series_namespace_implementation(
             self._expr,
             "str",
             "to_datetime",
@@ -315,43 +329,49 @@ class PandasExprDateTimeNamespace:
         self._expr = expr
 
     def year(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "year")
+        return reuse_series_namespace_implementation(self._expr, "dt", "year")
 
     def month(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "month")
+        return reuse_series_namespace_implementation(self._expr, "dt", "month")
 
     def day(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "day")
+        return reuse_series_namespace_implementation(self._expr, "dt", "day")
 
     def hour(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "hour")
+        return reuse_series_namespace_implementation(self._expr, "dt", "hour")
 
     def minute(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "minute")
+        return reuse_series_namespace_implementation(self._expr, "dt", "minute")
 
     def second(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "second")
+        return reuse_series_namespace_implementation(self._expr, "dt", "second")
 
     def millisecond(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "millisecond")
+        return reuse_series_namespace_implementation(self._expr, "dt", "millisecond")
 
     def microsecond(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "microsecond")
+        return reuse_series_namespace_implementation(self._expr, "dt", "microsecond")
 
     def ordinal_day(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "ordinal_day")
+        return reuse_series_namespace_implementation(self._expr, "dt", "ordinal_day")
 
     def total_minutes(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "total_minutes")
+        return reuse_series_namespace_implementation(self._expr, "dt", "total_minutes")
 
     def total_seconds(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "total_seconds")
+        return reuse_series_namespace_implementation(self._expr, "dt", "total_seconds")
 
     def total_milliseconds(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "total_milliseconds")
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "total_milliseconds"
+        )
 
     def total_microseconds(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "total_microseconds")
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "total_microseconds"
+        )
 
     def total_nanoseconds(self) -> PandasExpr:
-        return register_namespace_expression_call(self._expr, "dt", "total_nanoseconds")
+        return reuse_series_namespace_implementation(
+            self._expr, "dt", "total_nanoseconds"
+        )
diff --git a/narwhals/_pandas_like/group_by.py b/narwhals/_pandas_like/group_by.py
index 39c4979d..c979c843 100644
--- a/narwhals/_pandas_like/group_by.py
+++ b/narwhals/_pandas_like/group_by.py
@@ -22,6 +22,10 @@ if TYPE_CHECKING:
     from narwhals._pandas_like.expr import PandasExpr
     from narwhals._pandas_like.typing import IntoPandasExpr
 
+POLARS_TO_PANDAS_AGGREGATIONS = {
+    "len": "size",
+}
+
 
 class PandasGroupBy:
     def __init__(self, df: PandasDataFrame, keys: list[str]) -> None:
@@ -102,15 +106,17 @@ def agg_pandas(  # noqa: PLR0913
         simple_aggregations: dict[str, tuple[str, str]] = {}
         for expr in exprs:
             if expr._depth == 0:
-                # e.g. agg(pl.len())
+                # e.g. agg(nw.len())
                 assert expr._output_names is not None
+                function_name = POLARS_TO_PANDAS_AGGREGATIONS.get(
+                    expr._function_name, expr._function_name
+                )
                 for output_name in expr._output_names:
-                    simple_aggregations[output_name] = (
-                        keys[0],
-                        expr._function_name.replace("len", "size"),
-                    )
+                    simple_aggregations[output_name] = (keys[0], function_name)
                 continue
 
+            # e.g. agg(nw.mean('a'))
+            assert expr._depth == 1
             assert expr._root_names is not None
             assert expr._output_names is not None
             for root_name, output_name in zip(expr._root_names, expr._output_names):
diff --git a/narwhals/_pandas_like/series.py b/narwhals/_pandas_like/series.py
index fe7428cb..96d6004c 100644
--- a/narwhals/_pandas_like/series.py
+++ b/narwhals/_pandas_like/series.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Literal
 from typing import Sequence
 
 from narwhals._pandas_like.utils import item
@@ -473,6 +474,13 @@ class PandasSeries:
             implementation=self._implementation,
         )
 
+    def quantile(
+        self: Self,
+        quantile: float,
+        interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    ) -> Any:
+        return self._series.quantile(q=quantile, interpolation=interpolation)
+
     def zip_with(self: Self, mask: Any, other: Any) -> PandasSeries:
         ser = self._series
         res = ser.where(mask._series, other._series)
diff --git a/narwhals/_pandas_like/utils.py b/narwhals/_pandas_like/utils.py
index 1d387fbc..df5e423e 100644
--- a/narwhals/_pandas_like/utils.py
+++ b/narwhals/_pandas_like/utils.py
@@ -79,13 +79,13 @@ def validate_dataframe_comparand(index: Any, other: Any) -> Any:
     raise AssertionError("Please report a bug")
 
 
-def maybe_evaluate_expr(df: PandasDataFrame, arg: Any) -> Any:
-    """Evaluate expression if it's an expression, otherwise return it as is."""
+def maybe_evaluate_expr(df: PandasDataFrame, expr: Any) -> Any:
+    """Evaluate `expr` if it's an expression, otherwise return it as is."""
     from narwhals._pandas_like.expr import PandasExpr
 
-    if isinstance(arg, PandasExpr):
-        return arg._call(df)
-    return arg
+    if isinstance(expr, PandasExpr):
+        return expr._call(df)
+    return expr
 
 
 def parse_into_exprs(
@@ -93,6 +93,8 @@ def parse_into_exprs(
     *exprs: IntoPandasExpr | Iterable[IntoPandasExpr],
     **named_exprs: IntoPandasExpr,
 ) -> list[PandasExpr]:
+    """Parse each input as an expression (if it's not already one). See `parse_into_expr` for
+    more details."""
     out = [parse_into_expr(implementation, into_expr) for into_expr in flatten(exprs)]
     for name, expr in named_exprs.items():
         out.append(parse_into_expr(implementation, expr).alias(name))
@@ -100,6 +102,17 @@ def parse_into_exprs(
 
 
 def parse_into_expr(implementation: str, into_expr: IntoPandasExpr) -> PandasExpr:
+    """Parse `into_expr` as an expression.
+
+    For example, in Polars, we can do both `df.select('a')` and `df.select(pl.col('a'))`.
+    We do the same in Narwhals:
+
+    - if `into_expr` is already an expression, just return it
+    - if it's a Series, then convert it to an expression
+    - if it's a numpy array, then convert it to a Series and then to an expression
+    - if it's a string, then convert it to an expression
+    - else, raise
+    """
     from narwhals._pandas_like.expr import PandasExpr
     from narwhals._pandas_like.namespace import PandasNamespace
     from narwhals._pandas_like.series import PandasSeries
@@ -168,10 +181,23 @@ def evaluate_into_exprs(
     return series
 
 
-def register_expression_call(expr: ExprT, attr: str, *args: Any, **kwargs: Any) -> ExprT:
+def reuse_series_implementation(
+    expr: ExprT, attr: str, *args: Any, returns_scalar: bool = False, **kwargs: Any
+) -> ExprT:
+    """Reuse Series implementation for expression.
+
+    If Series.foo is already defined, and we'd like Expr.foo to be the same, we can
+    leverage this method to do that for us.
+
+    Arguments
+        expr: expression object.
+        attr: name of method.
+        returns_scalar: whether the Series version returns a scalar. In this case,
+            the expression version should return a 1-row Series.
+        args, kwargs: arguments and keyword arguments to pass to function.
+    """
     from narwhals._pandas_like.expr import PandasExpr
     from narwhals._pandas_like.namespace import PandasNamespace
-    from narwhals._pandas_like.series import PandasSeries
 
     plx = PandasNamespace(implementation=expr._implementation)
 
@@ -185,37 +211,51 @@ def register_expression_call(expr: ExprT, attr: str, *args: Any, **kwargs: Any)
                     for arg_name, arg_value in kwargs.items()
                 },
             )
-            if isinstance(_out, PandasSeries):
-                out.append(_out)
-            else:
+            if returns_scalar:
                 out.append(plx._create_series_from_scalar(_out, column))
-        if expr._output_names is not None:
+            else:
+                out.append(_out)
+        if expr._output_names is not None:  # safety check
             assert [s._series.name for s in out] == expr._output_names
         return out
 
+    # Try tracking root and output names by combining them from all
+    # expressions appearing in args and kwargs. If any anonymous
+    # expression appears (e.g. nw.all()), then give up on tracking root names
+    # and just set it to None.
     root_names = copy(expr._root_names)
+    output_names = expr._output_names
     for arg in list(args) + list(kwargs.values()):
         if root_names is not None and isinstance(arg, PandasExpr):
             if arg._root_names is not None:
                 root_names.extend(arg._root_names)
             else:
                 root_names = None
+                output_names = None
                 break
         elif root_names is None:
+            output_names = None
             break
 
+    assert (output_names is None and root_names is None) or (
+        output_names is not None and root_names is not None
+    )  # safety check
+
     return plx._create_expr_from_callable(  # type: ignore[return-value]
         func,
         depth=expr._depth + 1,
         function_name=f"{expr._function_name}->{attr}",
         root_names=root_names,
-        output_names=expr._output_names,
+        output_names=output_names,
     )
 
 
-def register_namespace_expression_call(
+def reuse_series_namespace_implementation(
     expr: ExprT, namespace: str, attr: str, *args: Any, **kwargs: Any
 ) -> PandasExpr:
+    """Just like `reuse_series_implementation`, but for e.g. `Expr.dt.foo` instead
+    of `Expr.foo`.
+    """
     from narwhals._pandas_like.expr import PandasExpr
 
     return PandasExpr(
@@ -240,13 +280,20 @@ def item(s: Any) -> Any:
 
 
 def is_simple_aggregation(expr: PandasExpr) -> bool:
-    return (
-        expr._function_name is not None
-        and expr._depth is not None
-        and expr._depth < 2
-        # todo: avoid this one?
-        and (expr._root_names is not None or (expr._depth == 0))
-    )
+    """
+    Check if expr is a very simple one, such as:
+
+    - nw.col('a').mean()  # depth 1
+    - nw.mean('a')  # depth 1
+    - nw.len()  # depth 0
+
+    as opposed to, say
+
+    - nw.col('a').filter(nw.col('b')>nw.col('c')).max()
+
+    because then, we can use a fastpath in pandas.
+    """
+    return expr._depth < 2
 
 
 def horizontal_concat(dfs: list[Any], implementation: str) -> Any:
diff --git a/narwhals/dataframe.py b/narwhals/dataframe.py
index 6962dc41..682a2263 100644
--- a/narwhals/dataframe.py
+++ b/narwhals/dataframe.py
@@ -53,6 +53,7 @@ class BaseFrame:
         )
 
     def _flatten_and_extract(self, *args: Any, **kwargs: Any) -> Any:
+        """Process `args` and `kwargs`, extracting underlying objects as we go."""
         from narwhals.utils import flatten
 
         args = [self._extract_native(v) for v in flatten(args)]  # type: ignore[assignment]
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 5f74352c..fbb292a4 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
 from typing import Any
 from typing import Callable
 from typing import Iterable
+from typing import Literal
 
 from narwhals.dependencies import get_polars
 from narwhals.dtypes import translate_dtype
@@ -1417,6 +1418,57 @@ class Expr:
         """
         return self.__class__(lambda plx: self._call(plx).is_last_distinct())
 
+    def quantile(
+        self,
+        quantile: float,
+        interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    ) -> Expr:
+        r"""Get quantile value.
+
+        Note:
+            pandas and Polars may have implementation differences for a given interpolation method.
+
+        Arguments:
+            quantile : float
+                Quantile between 0.0 and 1.0.
+            interpolation : {'nearest', 'higher', 'lower', 'midpoint', 'linear'}
+                Interpolation method.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = {'a': list(range(50)), 'b': list(range(50, 100))}
+            >>> df_pd = pd.DataFrame(data)
+            >>> df_pl = pl.DataFrame(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> def func(df_any):
+            ...     df = nw.from_native(df_any)
+            ...     result = df.select(nw.col('a', 'b').quantile(0.5, interpolation='linear'))
+            ...     return nw.to_native(result)
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(df_pd)  # doctest: +NORMALIZE_WHITESPACE
+                a   b
+            0  24.5  74.5
+
+            >>> func(df_pl)  # doctest: +NORMALIZE_WHITESPACE
+            shape: (1, 2)
+            ┌──────┬──────┐
+            │ a    ┆ b    │
+            │ ---  ┆ ---  │
+            │ f64  ┆ f64  │
+            ╞══════╪══════╡
+            │ 24.5 ┆ 74.5 │
+            └──────┴──────┘
+        """
+        return self.__class__(
+            lambda plx: self._call(plx).quantile(quantile, interpolation)
+        )
+
     @property
     def str(self) -> ExprStringNamespace:
         return ExprStringNamespace(self)
diff --git a/narwhals/series.py b/narwhals/series.py
index cdc189f8..466f05a9 100644
--- a/narwhals/series.py
+++ b/narwhals/series.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 from typing import TYPE_CHECKING
 from typing import Any
+from typing import Literal
 
 from narwhals.dtypes import to_narwhals_dtype
 from narwhals.dtypes import translate_dtype
@@ -1482,6 +1483,50 @@ class Series:
 
         return DataFrame(self._series.value_counts(sort=sort, parallel=parallel))
 
+    def quantile(
+        self,
+        quantile: float,
+        interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    ) -> Any:
+        """
+        Get quantile value of the series.
+
+        Note:
+            pandas and Polars may have implementation differences for a given interpolation method.
+
+        Arguments:
+            quantile : float
+                Quantile between 0.0 and 1.0.
+            interpolation : {'nearest', 'higher', 'lower', 'midpoint', 'linear'}
+                Interpolation method.
+
+        Examples:
+            >>> import narwhals as nw
+            >>> import pandas as pd
+            >>> import polars as pl
+            >>> data = list(range(50))
+            >>> s_pd = pd.Series(data)
+            >>> s_pl = pl.Series(data)
+
+            Let's define a dataframe-agnostic function:
+
+            >>> def func(s_any):
+            ...     series = nw.from_native(s_any, allow_series=True)
+            ...     return [
+            ...         series.quantile(quantile=q, interpolation='nearest')
+            ...         for q in (0.1, 0.25, 0.5, 0.75, 0.9)
+            ...         ]
+
+            We can then pass either pandas or Polars to `func`:
+
+            >>> func(s_pd)  # doctest: +NORMALIZE_WHITESPACE
+            [5, 12, 24, 37, 44]
+
+            >>> func(s_pl)  # doctest: +NORMALIZE_WHITESPACE
+            [5.0, 12.0, 25.0, 37.0, 44.0]
+        """
+        return self._series.quantile(quantile=quantile, interpolation=interpolation)
+
     def zip_with(self, mask: Any, other: Any) -> Self:
         """
         Take values from self or other based on the given mask. Where mask evaluates true, take values from self. Where mask evaluates false, take values from other.


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index 3f7e6fb8..90b5c21a 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -3,6 +3,7 @@ from __future__ import annotations
 import os
 import warnings
 from typing import Any
+from typing import Literal
 
 import numpy as np
 import pandas as pd
@@ -680,3 +681,28 @@ def test_null_count(df_raw: Any) -> None:
     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]}),
+    ],
+)
+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)
diff --git a/tests/test_series.py b/tests/test_series.py
index d54aabbd..0881fae7 100644
--- a/tests/test_series.py
+++ b/tests/test_series.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 from typing import Any
+from typing import Literal
 
 import numpy as np
 import pandas as pd
@@ -418,6 +419,29 @@ def test_is_sorted_invalid(df_raw: Any) -> None:
         series.is_sorted(descending="invalid_type")  # type: ignore[arg-type]
 
 
+@pytest.mark.parametrize("df_raw", [df_pandas, df_polars])
+@pytest.mark.parametrize(
+    ("interpolation", "expected"),
+    [
+        ("lower", 7.0),
+        ("higher", 8.0),
+        ("midpoint", 7.5),
+        ("linear", 7.6),
+        ("nearest", 8.0),
+    ],
+)
+def test_quantile(
+    df_raw: Any,
+    interpolation: Literal["nearest", "higher", "lower", "midpoint", "linear"],
+    expected: float,
+) -> None:
+    q = 0.3
+
+    series = nw.from_native(df_raw["z"], allow_series=True)
+    result = series.quantile(quantile=q, interpolation=interpolation)  # type: ignore[union-attr]
+    assert result == expected
+
+
 @pytest.mark.parametrize(
     ("df_raw", "mask", "expected"),
     [