Repository: narwhals-dev/narwhals

Issue:
[Enh]: support creating column with literal value
### We would like to learn about your use case. For example, if this feature is needed  to adopt Narwhals in an open source project, could you please enter the link to it below?


I'm currently looking at #204 , I found a place where this could be helpful

### Please describe the purpose of the new feature or describe the problem to solve.

Similar to [`polars.lit`](https://docs.pola.rs/api/python/stable/reference/expressions/api/polars.lit.html), it would be nice to have the capability to add a column with a constant value.

### Suggest a solution if possible.

I think we could have a `narwhals.lit` function with a similar signature as `polars.lit`.


### If you have tried alternatives, please describe them below.

_No response_

### Additional information that may help us understand your needs.

Happy to make a PR!

Discussion:
FBruzzesi: Additional note: when creating a new column without using `lit`, narwhals already handles numpy vs other well. 

```py
import narwhals as nw
import numpy as np
import polars as pl

(
  pl.DataFrame({"a": [0, 1]})
  .pipe(nw.from_native)
  .with_columns(
      b = ["x", "y"],
      c = np.array([1, 2]),
  )
  .pipe(nw.to_native)
)
```
```terminal
shape: (2, 3)
┌─────┬────────────┬─────┐
│ a   ┆ b          ┆ c   │
│ --- ┆ ---        ┆ --- │
│ i64 ┆ list[str]  ┆ i64 │
╞═════╪════════════╪═════╡
│ 0   ┆ ["x", "y"] ┆ 1   │
│ 1   ┆ ["x", "y"] ┆ 2   │
└─────┴────────────┴─────┘
```
@MarcoGorelli  I remember we discussed this since it was needed in lego, but I can't find the conversation right now 
EdAbati: Thank you both for the comments! :)

a. Passing a `list` into `lit` seems to work.

```python
import polars as pl
import pandas as pd
import narwhals as nw

df = pd.DataFrame({"a": [0, 1]})
df_pl = pl.DataFrame(df)

@nw.narwhalify
def func(df):
    return df.with_columns(c=nw.lit([1, 2]))
```

```
>>> func(df_pl)
shape: (2, 2)
┌─────┬───────────┐
│ a   ┆ c         │
│ --- ┆ ---       │
│ i64 ┆ list[i64] │
╞═════╪═══════════╡
│ 0   ┆ [1, 2]    │
│ 1   ┆ [1, 2]    │
└─────┴───────────┘

>>> func(df)
   a       c
0  0  [1, 2]
1  1  [1, 2]
```

The only "issue" is that we cannot specify the correct `DType` (i.e. `nw.lit([1, 2], dtype=...)` just yet, because we don't have a list dtype. Or am I missing something?

b. Passing a `np.array` makes pandas and polars behave differently at the moment. 🤔

```python

@nw.narwhalify
def func(df):
    import numpy as np

    return df.with_columns(c=nw.lit(np.array([1, 2])))
```

```
>>> func(df_pl)
shape: (2, 2)
┌─────┬─────┐
│ a   ┆ c   │
│ --- ┆ --- │
│ i64 ┆ i64 │
╞═════╪═════╡
│ 0   ┆ 1   │
│ 1   ┆ 2   │
└─────┴─────┘

>>> func(df)
   a       c
0  0  [1, 2]
1  1  [1, 2]
```

MarcoGorelli: thanks for checking that carefully - I think we should disallow passing numpy arrays to `lit`? at least for now - if we just raise an error for now, we can always loosen the behaviour later if we want to, whilst preserving backwards compatibility 😇 

>  just yet, because we don't have a list dtype. Or am I missing something?

you're right, we don't :) largely because potential consumers of Narwhals probably wouldn't be using it, given that in pandas pre-pyarrow it would just be `object` dtype ... but we should implement it properly in Narwhals eventually, it's definitely worthwhile
EdAbati: > how did you go about figuring this all out? did you read the "how it works" page? did you use a debugger / read the code very carefully?

Kind of both. I read the "how it work works" page out of curiosity, when I found out about the project. To contribute to this I read the code, searching for similar implementations and used the debugger.

I found the code easy to follow, also thanks to the typing. So 👏 👏  to you and the rest of the team

The contributing guide and process (with the template issues and PRs) is also nice!

Maybe we can add some documentation about the structure of the unit tests. I am used to find the same module/submodule structure as in the library. Here it seems the tests cases in the same submodule are in more scripts. I was a bit unsure where to add mine 😅

Regarding variable names, the only thing I noticed is the one I mentioned above. I was unsure why we needed to use `PandasSeries`, before realising that `series_from_iterables` was returning a ‘native series’.


Patch:
diff --git a/docs/api-reference/narwhals.md b/docs/api-reference/narwhals.md
index 5b623303..e2246c26 100644
--- a/docs/api-reference/narwhals.md
+++ b/docs/api-reference/narwhals.md
@@ -15,6 +15,7 @@ Here are the top-level functions available in Narwhals.
         - maybe_align_index
         - maybe_set_index
         - maybe_convert_dtypes
+        - lit
         - max
         - mean
         - min
diff --git a/narwhals/__init__.py b/narwhals/__init__.py
index 94c0294a..6363ffff 100644
--- a/narwhals/__init__.py
+++ b/narwhals/__init__.py
@@ -19,6 +19,7 @@ from narwhals.expression import Expr
 from narwhals.expression import all
 from narwhals.expression import col
 from narwhals.expression import len
+from narwhals.expression import lit
 from narwhals.expression import max
 from narwhals.expression import mean
 from narwhals.expression import min
@@ -50,6 +51,7 @@ __all__ = [
     "all",
     "col",
     "len",
+    "lit",
     "min",
     "max",
     "mean",
diff --git a/narwhals/_pandas_like/namespace.py b/narwhals/_pandas_like/namespace.py
index 0606ecdc..bebd4ae9 100644
--- a/narwhals/_pandas_like/namespace.py
+++ b/narwhals/_pandas_like/namespace.py
@@ -108,6 +108,30 @@ class PandasNamespace:
             implementation=self._implementation,
         )
 
+    def lit(self, value: Any, dtype: dtypes.DType | None) -> PandasExpr:
+        def _lit_pandas_series(df: PandasDataFrame) -> PandasSeries:
+            pandas_series = PandasSeries(
+                series_from_iterable(
+                    data=[value],
+                    name="lit",
+                    index=df._dataframe.index[0:1],
+                    implementation=self._implementation,
+                ),
+                implementation=self._implementation,
+            )
+            if dtype:
+                return pandas_series.cast(dtype)
+            return pandas_series
+
+        return PandasExpr(
+            lambda df: [_lit_pandas_series(df)],
+            depth=0,
+            function_name="lit",
+            root_names=None,
+            output_names=["lit"],
+            implementation=self._implementation,
+        )
+
     # --- reduction ---
     def sum(self, *column_names: str) -> PandasExpr:
         return PandasExpr.from_column_names(
diff --git a/narwhals/expression.py b/narwhals/expression.py
index 3f523aa3..a8c09368 100644
--- a/narwhals/expression.py
+++ b/narwhals/expression.py
@@ -6,7 +6,9 @@ from typing import Callable
 from typing import Iterable
 from typing import Literal
 
+from narwhals.dependencies import get_numpy
 from narwhals.dependencies import get_polars
+from narwhals.dtypes import DType
 from narwhals.dtypes import translate_dtype
 from narwhals.utils import flatten
 from narwhals.utils import parse_version
@@ -3070,6 +3072,60 @@ def sum_horizontal(*exprs: IntoExpr | Iterable[IntoExpr]) -> Expr:
     )
 
 
+def lit(value: Any, dtype: DType | None = None) -> Expr:
+    """
+    Return an expression representing a literal value.
+
+    Arguments:
+        value: The value to use as literal.
+        dtype: The data type of the literal value. If not provided, the data type will be inferred.
+
+    Examples:
+        >>> import pandas as pd
+        >>> import polars as pl
+        >>> import narwhals as nw
+        >>> df_pl = pl.DataFrame({"a": [1, 2]})
+        >>> df_pd = pd.DataFrame({"a": [1, 2]})
+
+        We define a dataframe-agnostic function:
+
+        >>> @nw.narwhalify
+        ... def func(df):
+        ...     return df.with_columns(nw.lit(3).alias("b"))
+
+        We can then pass either pandas or polars to `func`:
+
+        >>> func(df_pd)
+           a  b
+        0  1  3
+        1  2  3
+        >>> func(df_pl)
+        shape: (2, 2)
+        ┌─────┬─────┐
+        │ a   ┆ b   │
+        │ --- ┆ --- │
+        │ i64 ┆ i32 │
+        ╞═════╪═════╡
+        │ 1   ┆ 3   │
+        │ 2   ┆ 3   │
+        └─────┴─────┘
+
+    """
+    if (np := get_numpy()) is not None and isinstance(value, np.ndarray):
+        raise ValueError(
+            "numpy arrays are not supported as literal values. "
+            "Consider using `with_columns` to create a new column from the array."
+        )
+
+    if isinstance(value, (list, tuple)):
+        msg = f"Nested datatypes are not supported yet. Got {value}"
+        raise NotImplementedError(msg)
+
+    if dtype is None:
+        return Expr(lambda plx: plx.lit(value, dtype))
+    return Expr(lambda plx: plx.lit(value, translate_dtype(plx, dtype)))
+
+
 __all__ = [
     "Expr",
 ]


Test patch:
diff --git a/tests/test_common.py b/tests/test_common.py
index fbcf455e..01f98c59 100644
--- a/tests/test_common.py
+++ b/tests/test_common.py
@@ -15,6 +15,7 @@ from polars.testing import assert_series_equal as pl_assert_series_equal
 from sklearn.utils._testing import ignore_warnings
 
 import narwhals as nw
+from narwhals import dtypes
 from narwhals.functions import _get_deps_info
 from narwhals.functions import _get_sys_info
 from narwhals.functions import show_versions
@@ -230,6 +231,45 @@ def test_sum_all(df_raw: Any) -> None:
     compare_dicts(result_native, expected)
 
 
+@pytest.mark.parametrize(
+    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
+)
+@pytest.mark.parametrize(
+    ("dtype", "expected_lit"),
+    [(None, [2, 2, 2]), (nw.String, ["2", "2", "2"]), (nw.Float32, [2.0, 2.0, 2.0])],
+)
+def test_lit(df_raw: Any, dtype: dtypes.DType | None, expected_lit: list[Any]) -> None:
+    df = nw.LazyFrame(df_raw)
+    result = df.with_columns(nw.lit(2, dtype).alias("lit"))
+    result_native = nw.to_native(result)
+    expected = {
+        "a": [1, 3, 2],
+        "b": [4, 4, 6],
+        "z": [7.0, 8.0, 9.0],
+        "lit": expected_lit,
+    }
+    compare_dicts(result_native, expected)
+
+
+@pytest.mark.parametrize(
+    "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
+)
+def test_lit_error(df_raw: Any) -> None:
+    df = nw.LazyFrame(df_raw)
+    with pytest.raises(
+        ValueError, match="numpy arrays are not supported as literal values"
+    ):
+        _ = df.with_columns(nw.lit(np.array([1, 2])).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit((1, 2)).alias("lit"))
+    with pytest.raises(
+        NotImplementedError, match="Nested datatypes are not supported yet."
+    ):
+        _ = df.with_columns(nw.lit([1, 2]).alias("lit"))
+
+
 @pytest.mark.parametrize(
     "df_raw", [df_pandas, df_lazy, df_pandas_nullable, df_pandas_pyarrow]
 )