Skip to content

feat: add nw.list#3694

Open
CangyuanLi wants to merge 20 commits into
narwhals-dev:mainfrom
CangyuanLi:list-constructor
Open

feat: add nw.list#3694
CangyuanLi wants to merge 20 commits into
narwhals-dev:mainfrom
CangyuanLi:list-constructor

Conversation

@CangyuanLi

@CangyuanLi CangyuanLi commented Jun 19, 2026

Copy link
Copy Markdown

Description

Adds a nw.list function that constructs a list datatype from columns. It mimics duckdb's list_pack semantics. Scalars are collected into a list and lists are collected into a list of lists.

If you have comments or want to explain your changes, please do so in this section

I basically tried to mimic nw.struct wherever possible. For example, since nw.struct is not implemented for dask, neither is nw.list.

I also added a scalars_only flag which is false by default. This allows us to directly use concat_list for the polars backend without calling .implode().over(). It does nothing for all other backends.

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

Checklist

  • Code follows style guide (ruff)
  • Tests added
  • Documented the changes

@CangyuanLi CangyuanLi mentioned this pull request Jun 19, 2026
1 task

@MarcoGorelli MarcoGorelli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks @CangyuanLi !

my main question is - what's scalars_only?

Comment thread src/narwhals/functions.py Outdated
Comment on lines +1995 to +2004
>>> data = {
... "a": [1, 2, 3],
... "b": [4, None, 6],
... "d": [[10, 20], None, [40, 50, 60]],
... "e": [[100], [200, 300], [None]],
... }
>>> df = nw.from_native(pl.DataFrame(data))
>>> df.select(
... nw.list("a", "b").alias("list"), nw.list("d", "e").alias("list_of_lists")
... )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we keep it simple and just have:

  • a
  • b

and use with_columns to make a a_b column?

@CangyuanLi CangyuanLi Jun 19, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed!

@CangyuanLi

Copy link
Copy Markdown
Author

thanks @CangyuanLi !

my main question is - what's scalars_only?

it is only relevant for thr polars backend and uses concat_list directly instead of the .implode().over() trick. I was worried about the potential performance impact but maybe its leaking implementation details too much?

@CangyuanLi CangyuanLi requested a review from MarcoGorelli June 19, 2026 21:22
@CangyuanLi

Copy link
Copy Markdown
Author

The stable api tests are failing

=================================== short test summary info ===================================
FAILED tests/stable_api_test.py::test_stable_api_docstrings - AttributeError: module 'narwhals.stable.v2' has no attribute 'list'. Did you mean: 'List'?
FAILED tests/stable_api_test.py::test_stable_api_completeness - AssertionError: assert not {'list'}

would you want me to edit the tests to explicitly xfail list, or add it to the stable api?

@CangyuanLi

CangyuanLi commented Jun 20, 2026

Copy link
Copy Markdown
Author

thanks @CangyuanLi !
my main question is - what's scalars_only?

it is only relevant for thr polars backend and uses concat_list directly instead of the .implode().over() trick. I was worried about the potential performance impact but maybe its leaking implementation details too much?

The performance impact is relatively minimal for concatenating two columns. It's only really much faster for very small dataframes.

image

However, it does appear to make a pretty big difference as the number of columns to concatenate increases.

image

Timings are the median timing of 50 runs, with the CIs representing the 5th and 95th percentile. At 50 columns and 1 million rows, the median time using scalars_only=False 26.26 seconds. Using scalars_only=True, the median time is 8.9694 seconds, a roughly 2.93x speedup.

@MarcoGorelli MarcoGorelli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks! a few comments

Comment thread src/narwhals/_duckdb/namespace.py Outdated
Comment thread src/narwhals/_pandas_like/namespace.py Outdated
Comment thread src/narwhals/_pandas_like/namespace.py Outdated
Comment thread src/narwhals/_polars/namespace.py Outdated
Comment thread src/narwhals/_polars/namespace.py Outdated
Comment on lines +248 to +262
narwhals_to_native_dtype(dtype, self._version).__class__
if isinstance(dtype, type) and issubclass(dtype, DType)
else narwhals_to_native_dtype(dtype, self._version)
(
narwhals_to_native_dtype(dtype, self._version).__class__
if isinstance(dtype, type) and issubclass(dtype, DType)
else narwhals_to_native_dtype(dtype, self._version)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unrelated change?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a change that prek made (probably the ruff-format portion). I can revert it and commit with skip checks.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff-format is passing on main so it wouldn't have been that - you can revert this part anyway, the checks need to pass for the pr to be mergeable

Comment thread src/narwhals/functions.py Outdated

@MarcoGorelli MarcoGorelli left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for updating!

some comments

can you also make sure some tests include null values?

Comment thread src/narwhals/_arrow/namespace.py Outdated
Comment on lines +276 to +298
def corr(self, a: ArrowExpr, b: ArrowExpr, *, method: CorrelationMethod) -> ArrowExpr:
def corr(
self, a: ArrowExpr, b: ArrowExpr, *, method: CorrelationMethod
) -> ArrowExpr:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we revert this (and any other cosmetic-only changes)?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so strange, ruff-format is passing on my end so I'm not sure what's going on

image

but will work on reverting all of the cosmetic changes, sorry for the extra noise!

Comment thread src/narwhals/_polars/namespace.py Outdated
Comment on lines +248 to +262
narwhals_to_native_dtype(dtype, self._version).__class__
if isinstance(dtype, type) and issubclass(dtype, DType)
else narwhals_to_native_dtype(dtype, self._version)
(
narwhals_to_native_dtype(dtype, self._version).__class__
if isinstance(dtype, type) and issubclass(dtype, DType)
else narwhals_to_native_dtype(dtype, self._version)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff-format is passing on main so it wouldn't have been that - you can revert this part anyway, the checks need to pass for the pr to be mergeable

Comment thread src/narwhals/_arrow/namespace.py Outdated
Comment on lines +142 to +144
init_series, *series = tuple(chain.from_iterable(expr(df) for expr in exprs))
init_series, *series = tuple(
chain.from_iterable(expr(df) for expr in exprs)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread src/narwhals/_arrow/namespace.py Outdated
Comment on lines +161 to +163
init_series, *series = tuple(
chain.from_iterable(expr(df) for expr in exprs)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread src/narwhals/_arrow/namespace.py Outdated
Comment on lines +232 to +238
init_series, *series = align(*chain.from_iterable(expr(df) for expr in exprs))
init_series, *series = align(
*chain.from_iterable(expr(df) for expr in exprs)
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

Comment thread src/narwhals/_arrow/utils.py Outdated
Comment on lines +94 to +104
def build_list_array(arrays: list[pa.Array]) -> pa.ChunkedArray:
# This works by using concat_arrays to vertically stack the arrays.
# Then we use take to grab the data by index and to horizontally
# pack the arrays.
n = len(arrays[0])
num_cols = len(arrays)
flat = pa.concat_arrays(arrays)
indices = [j * n + i for i in range(n) for j in range(num_cols)]
interleaved = pc.take(flat, pa.array(indices))
offsets = pa.array(range(0, n * num_cols + 1, num_cols), type=pa.int32())
return pa.chunked_array([pa.ListArray.from_arrays(offsets, interleaved)])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, clever! out of interest, where does this solution come from?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found concat_arrays, realized it didn't do what I wanted, then I found ListArray.from_arrays, and found it confusing, and then I asked Claude to mash them together.

@CangyuanLi

Copy link
Copy Markdown
Author

i think I fixed all the formatting errors, as well as a typing error. for the stable api tests I followed "from_dict" excluded list from the tests. I also added the nulls in the test data. With the default backend for pandas, something like [None, 5] gets converted to a float, so I only tested pyarrow-backed pandas.

Sorry again for the formatting noise and thanks for your patience!

@MarcoGorelli

Copy link
Copy Markdown
Member

thanks for updating! there may be some coverage issue, check if all lines are covered (or use # pragma: no cover if necessary), and if you fetch and merge upstream/main then the unrelated ci issues should be fixed

@CangyuanLi

Copy link
Copy Markdown
Author

thanks for updating! there may be some coverage issue, check if all lines are covered (or use # pragma: no cover if necessary), and if you fetch and merge upstream/main then the unrelated ci issues should be fixed

the coverage issue should be fixed! I used no cover for the branch where polars is > 2.0 since it is not out yet

@CangyuanLi

Copy link
Copy Markdown
Author

Another try to fix CI errors--old polars versions now use pl.count() instead of pl.len(). I also removed what looked to be an extraneous type ignore

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enh]: add nw.concat_list

2 participants