mrkeyoor.com_
Sun 20 Sept 07:02 UTC
PyPIDataupdated 20 Sept 2026

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.

Verdict

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

Lab card: what happened when we installed narwhalsScreenshot of narwhals documentation
Install✓ · 0.2s1 package on disk · 3 MB
Importimport narwhals in 0.47s · pure Python · py.typed · requires Python >=3.10
Known vulns0(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.

API stability5/5Narwhals publishes stable namespaces such as `narwhals.stable.v2` so downstream libraries can opt into a fixed contract while the main namespace continues to grow. The wrap, expression, and conversion model remains consistent for eager and lazy inputs. Version 2.25.0 adds methods and backend corrections without breaking the stable surface. Mixing objects from main and stable namespaces still creates avoidable incompatibilities, so the chosen import path belongs in a library's public policy.
Docs5/5The documentation separates full-support backends from lazy-only backends, publishes the supported expression API, and has focused pages for stable imports, typing, overhead, indexes, ordering, nulls, and library authors. That level of specificity fits a compatibility layer where backend differences are part of the job. Native engine docs remain necessary for execution plans, collection costs, and backend errors, since Narwhals intentionally delegates those details instead of hiding them.
Maintenance5/5GitHub showed an unarchived repository with 1,708 stars, 244 open issues and pull requests together, and a push on 2026-08-26. Version 2.25.0 shipped on 2026-08-20 with performance work, 4 user-facing additions, and fixes spanning pandas-like frames, PyArrow, Ibis, strings, dates, categoricals, indexes, and nulls. That active pace is good for backend coverage and is also the reason library authors should prefer the stable namespace.
Ecosystem4/5PyPI Stats recorded 35,800,118 downloads in the last week. The README lists downstream users across plotting, validation, statistics, and machine learning, including Altair, Bokeh, LightGBM, Pandera, Plotly, and scikit-learn. Many developers therefore receive Narwhals indirectly rather than choosing it in application code. Its reach is broad, but useful compatibility still depends on each downstream maintainer testing the exact backend combinations advertised to users.

Discussed on

  1. hnVideo Solves Mystery of How Narwhals Use Their Tusks112 points
  2. hnDrone captures narwhals using their tusks to explore, forage and play86 points
  3. hnResearchers confirm that narwhals and belugas can interbreed83 points
  4. hnFor a dentist, the narwhal’s smile is a mystery of evolution (2012)66 points
  5. 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.
Skip it if

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

PackageRegistryPick it when
ibis-frameworkPyPIUse it when portable expressions mainly target SQL engines and distributed query systems.
polarsPyPIUse it directly when one eager and lazy dataframe engine is enough.
pandasPyPIUse 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.