narwhals review
Narwhals 2.25.0 is an adapter for Python libraries that accept dataframes from pandas, Polars, PyArrow, cuDF, Modin, DuckDB, Dask, Ibis, PySpark, and other engines. Library code wraps a native object, uses Narwhals' supported Polars-like expressions, and returns the caller's original dataframe family. The native backend still performs the computation. Version 2.25.0 adds start/end character stripping, ordered list uniqueness, typed plugin names, and Ibis list medians, alongside fixes for pandas index alignment, null propagation, PyArrow medians, and categorical ordering.
Narwhals 2.25.0 installed as one 3 MB package in 0.2 seconds and imported in 0.47 seconds in our sandbox, with 0 audit findings. It earns its place at a library boundary where callers control dataframe choice; an application standardized on one engine should use that engine directly.
We installed it
| Install | ✓ · 0.2s | 1 package on disk · 3 MB |
| Import | ✓ | import narwhals in 0.47s · pure Python · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does narwhals install cleanly?
Yes. In a fresh container with an empty cache, pip install narwhals finished in 0.2s, leaving 1 package and 3 MB on disk. pip-audit reported no known vulnerabilities.
What does narwhals need to run?
Python >=3.10, and nothing compiled: it is pure Python. In our run import narwhals succeeded in 0.47s, and the package ships py.typed for type checkers.
narwhals or ibis-framework: which should you use?
ibis-framework: Use it when portable expressions mainly target SQL engines and distributed query systems. Narwhals 2.25.0 installed as one 3 MB package in 0.2 seconds and imported in 0.47 seconds in our sandbox, with 0 audit findings.
When should you not use narwhals?
Your application owns the dataframe choice. Direct pandas or Polars code is shorter and exposes that engine's full API.
Discussed on
- hnVideo Solves Mystery of How Narwhals Use Their Tusks112 points
- hnDrone captures narwhals using their tusks to explore, forage and play86 points
- hnResearchers confirm that narwhals and belugas can interbreed83 points
- hnFor a dentist, the narwhal’s smile is a mystery of evolution (2012)66 points
- hnNarwhals Are Helping NASA Understand Melting Ice and Rising Seas15 points
Use it if
- You maintain a plotting, validation, statistics, or machine-learning library whose callers choose their dataframe implementation.
- Adding support for one more backend must not make that backend a mandatory import for every user.
- DuckDB, Dask, Ibis, PySpark, or another lazy source must stay lazy through the transformation boundary.
- Published integration code needs a versioned stable namespace with a stated compatibility policy.
- Your application owns the dataframe choice. Direct pandas or Polars code is shorter and exposes that engine's full API.
- The required operation is missing from Narwhals' documented subset on any backend you promise to support.
- Code reads shape, rows, or eager indexes before collection while accepted inputs include lazy-only query engines.
- You expect Narwhals to accelerate pandas or normalize every null, index, and ordering rule. Work remains in the native engine and its semantics remain visible.
- The team cannot run behavioral tests across its advertised backend versions. A translated expression can still differ in group order, dtype coercion, indexes, or null handling.
- Returning one normalized dataframe type is acceptable. Converting at the boundary is simpler than preserving every caller's native type through an abstraction layer.
Setup reality
Our Narwhals 2.25.0 install completed in 0.2 seconds on Python 3.12. It left one package and 3 MB on disk; import narwhals worked in 0.47 seconds. pip-audit reported 0 known vulnerabilities. The distribution is pure Python, requires Python 3.10 or newer, includes py.typed, and its measured metadata listed 15 direct dependency entries. The installed metadata did not identify a license, while GitHub labels the repository MIT.
There are no credentials or configuration files. Narwhals does not install pandas, Polars, PyArrow, or another dataframe engine in our base environment, as shown by the one-package result. The caller must supply a supported native object. A library that accepts several backends still needs a test matrix covering those backends and the versions it claims, even when imports remain optional.
Published libraries should choose a versioned namespace such as narwhals.stable.v2 and use it consistently. Main-namespace and stable-namespace objects should not be mixed in one transformation. Pass eager_only=True at the boundary when code needs shape, direct rows, or other materialized behavior. Without that guard, a lazy DuckDB or Dask relation can travel into code that assumes an eager frame.
The adapter preserves backend execution, so semantics still need explicit choices. Sort after grouping when row order matters, test index alignment on pandas-like inputs, and expect lazy schema discovery to ask the backend for metadata. Version 2.25.0 fixed left-index alignment during pandas-like concatenation plus several null-preservation cases. Those fixes are useful evidence that cross-backend tests must assert values and order rather than only the returned object type.
Patterns
Preserve the caller's dataframe family wrap-transform-unwrap
import narwhals.stable.v2 as nw
from narwhals.stable.v2.typing import IntoFrameT
def add_total(frame: IntoFrameT) -> IntoFrameT:
return (
nw.from_native(frame)
.with_columns(total=nw.col('price') * nw.col('quantity'))
.to_native()
)A pandas input returns pandas and a Polars input returns Polars. Use a stable namespace in public library code.
Convert a function boundary automatically decorate-function
import narwhals.stable.v2 as nw
from narwhals.stable.v2.typing import FrameT
@nw.narwhalify
def summarize(frame: FrameT) -> FrameT:
return (
frame.group_by('region')
.agg(nw.col('sales').sum())
)`narwhalify` converts supported arguments and the returned frame. Use explicit wrapping when only one nested value should change.
Reject lazy input immediately require-eager-frame
import narwhals.stable.v2 as nw
frame = nw.from_native(user_frame, eager_only=True)
rows, columns = frame.shape`eager_only=True` raises at the boundary for a lazy relation. That is clearer than failing later on `shape` or row access.
Return a native lazy relation preserve-lazy-query
wrapped = nw.from_native(user_frame)
query = (
wrapped.filter(nw.col('active'))
.group_by('region')
.agg(revenue=nw.col('amount').sum())
)
native_query = query.to_native()`to_native()` on a lazy wrapper returns the backend's lazy object. It does not collect rows or choose a new execution engine.
Materialize into a chosen backend collect-query
lazy = nw.from_native(native_query)
result = (
lazy.group_by('region')
.agg(nw.col('amount').sum())
.collect(backend='polars')
)
native_result = result.to_native()Choosing `backend='polars'` makes Polars a runtime requirement and may copy data during materialization.
Make grouped order deterministic sort-grouped-output
result = (
nw.from_native(frame)
.group_by('region')
.agg(revenue=nw.col('amount').sum(), orders=nw.len())
.sort('region')
.to_native()
)Backends do not promise the same group order. Sort before snapshots, serialized output, or row-by-row comparisons.
Create a portable conditional expression build-conditional-column
result = (
nw.from_native(frame)
.with_columns(
band=(
nw.when(nw.col('score') >= 80)
.then(nw.lit('high'))
.otherwise(nw.lit('normal'))
)
)
.to_native()
)Null behavior still comes from the backend implementation. Test conditional results on every engine your library claims to support.
Select numeric and named columns select-dtypes
import narwhals.selectors as ncs
result = (
nw.from_native(frame)
.select(ncs.numeric() | ncs.matches('^id_'))
.to_native()
)Narwhals selectors replace backend-specific calls such as pandas `select_dtypes`. Supported dtype families still vary with the native engine.
Find numeric columns from a schema inspect-schema
wrapped = nw.from_native(frame)
schema = wrapped.collect_schema()
numeric = [
name
for name, dtype in schema.items()
if dtype.is_numeric()
]Schema collection on a lazy source can trigger backend metadata work even though it does not collect the full result.
Leave unsupported values untouched pass-through-unknown
value = nw.from_native(candidate, pass_through=True)
if isinstance(value, (nw.DataFrame, nw.LazyFrame)):
value = value.select('id')Without `pass_through=True`, unsupported input raises `TypeError`. That stricter default is safer for dataframe-only functions.
Normalize an accepted frame to pandas convert-to-pandas
import pandas as pd
import narwhals.stable.v2 as nw
from narwhals.stable.v2.typing import IntoDataFrame
def as_pandas(frame: IntoDataFrame) -> pd.DataFrame:
return nw.from_native(frame).to_pandas()`to_pandas()` is eager, can copy the data, and makes pandas a required dependency for this function.
Choose a tested backend fast path detect-native-backend
from narwhals.dependencies import is_pandas_dataframe
if is_pandas_dataframe(native_frame):
result = pandas_fast_path(native_frame)
else:
result = portable_path(native_frame)Dependency helpers detect native objects without importing the backend. Each special path adds another behavior branch to the test matrix.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| ibis-framework | PyPI | Use it when portable expressions mainly target SQL engines and distributed query systems. |
| polars | PyPI | Use it directly when one eager and lazy dataframe engine is enough. |
| pandas | PyPI | Use it directly when every supported caller already exchanges pandas objects. |
More data guides
numpy · fsspec · pandas · sqlalchemy · pyarrow · lxml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

