mrkeyoor.com_
Thu 06 Aug 02:45 UTC
PyPIUtilsupdated 06 Aug 2026

narwhals

Narwhals lets you write dataframe code once and run it on whatever dataframe your user handed you. You wrap the input with nw.from_native(), write your logic in a subset of the Polars API (col, with_columns, group_by, when/then), then call to_native() to hand back an object of the same type you received: pandas in, pandas out; Polars in, Polars out; DuckDB relation in, DuckDB relation out. It has no required dependencies of its own, because it only calls methods on the object you passed in. The target audience is library maintainers rather than analysts: Plotly, Altair, scikit-lego, marimo, and pandera use it so they can accept several dataframe types without importing any of them.

Verdict

If you ship a library that accepts user dataframes, this is the cleanest way to support pandas, Polars, PyArrow, and the lazy engines without depending on any of them. If you are writing application code against a dataframe library you already chose, it is an abstraction you do not need.

API stability5/5The versioned stable namespaces are the whole point: narwhals.stable.v1 and v2 are promised to keep working indefinitely, and breaking changes land in the main namespace and later stable editions instead
Docs4/5The site has per-backend API completeness tables, an overhead page with measurements, and concept pages on null handling, order dependence, and the pandas index; examples lean toward library authors, so application developers hit fewer worked recipes
Maintenance5/5Pushed August 2026, 2.24.0 released July 2026, and a release cadence measured in days; 194 open issues (245 counting PRs) on an active tracker with several regular contributors and institutional funding
Ecosystem4/5Around 35.6M weekly downloads driven by dependents such as Plotly, Altair, bokeh, scikit-lego, pandera, and marimo; only 1.7k stars because it is infrastructure most users never import directly, and there is no plugin scene beyond backend support

Use it if

  • You maintain a library that takes dataframes from users and you are tired of isinstance branches or forcing everything through pandas conversion
  • You want to add Polars, PyArrow, or DuckDB support to an existing pandas-based package without taking on those packages as dependencies
  • You want to write a transformation once and have it stay lazy on DuckDB, PySpark, or Dask while still running eagerly on pandas
  • You need a stability guarantee stronger than a changelog: narwhals.stable.v1 and narwhals.stable.v2 are promised to keep working indefinitely, even as the main namespace changes
Skip it if

Setup reality

pip install narwhals with no required dependencies and Python 3.10 or newer. Optional extras (narwhals[polars], [duckdb], [pyspark], [pandas]) only pin the backends you test against; nothing is installed for you at runtime. The decisions come after install. First, which namespace to import: plain narwhals moves, narwhals.stable.v2 is the frozen surface a library should depend on, and mixing the two in one codebase gives confusing type errors. Second, whether to accept lazy inputs: if your function needs eager results you must pass eager_only=True to from_native and document that, otherwise a DuckDB relation reaches code that assumes a materialized frame. Third, testing costs multiply, because supporting five backends means a CI matrix that installs five backends, and each has its own null and ordering behavior that Narwhals documents rather than hides.

Patterns

Write one function that accepts any dataframewrap-compute-unwrap

import narwhals as nw
from narwhals.typing import IntoFrameT


def with_category(df_native: IntoFrameT) -> IntoFrameT:
    return (
        nw.from_native(df_native)
        .with_columns(
            category=nw.when(nw.col("animal").str.contains("whale"))
            .then(nw.lit("whale"))
            .otherwise(nw.lit("other"))
        )
        .to_native()
    )

The return type matches the input type: pandas in, pandas out. Nothing is computed for lazy inputs until the caller collects.

Skip the wrap and unwrap boilerplatenarwhalify-decorator

import narwhals as nw
from narwhals.typing import FrameT


@nw.narwhalify
def top_by_group(df: FrameT) -> FrameT:
    return df.group_by("a").agg(nw.col("b").mean()).sort("a")

The decorator calls from_native on arguments and to_native on the result. Use the explicit form when a function takes several frames or non-frame arguments you do not want touched.

Depend on the frozen API in a librarystable-api-import

import narwhals.stable.v2 as nw
from narwhals.stable.v2.typing import IntoFrameT


def func(df: IntoFrameT) -> IntoFrameT:
    return nw.from_native(df).with_columns(nw.col("a").cum_sum()).to_native()

Published libraries should import a stable namespace, not plain narwhals. Objects from narwhals and narwhals.stable.v2 are different classes, so do not mix them in the same pipeline.

Reject lazy inputs when you need materialized dataeager-only-input

df = nw.from_native(user_input, eager_only=True)
print(df.shape)
print(df["a"].mean())

# accept anything, and only then decide:
frame = nw.from_native(user_input)
if isinstance(frame, nw.LazyFrame):
    frame = frame.collect()

eager_only=True raises immediately for a DuckDB relation or Polars LazyFrame, which is a much clearer failure than an AttributeError three calls later.

Let non-dataframe arguments flow through untouchedpass-through-non-frames

obj = nw.from_native(maybe_a_frame, pass_through=True)

if isinstance(obj, (nw.DataFrame, nw.LazyFrame)):
    obj = obj.select(nw.col("a").sum())
# lists, dicts, and numpy arrays come back unchanged

Without pass_through=True, from_native raises TypeError on anything it does not recognize. This is the switch that lets a plotting function accept both a dataframe and a plain dict.

Group and aggregate with expressionsgroup-by-aggregate

result = (
    nw.from_native(df_native)
    .group_by("region")
    .agg(
        revenue=nw.col("amount").sum(),
        orders=nw.len(),
        avg_item=nw.col("amount").mean(),
    )
    .sort("revenue", descending=True)
    .to_native()
)

Group-by output order is not guaranteed across backends, so sort explicitly if your tests compare frames row by row.

Stay lazy on DuckDB or PySpark, then materializelazy-backend-collect

import duckdb
import narwhals as nw

rel = duckdb.sql("select * from read_parquet('sales.parquet')")
lf = nw.from_native(rel)

agg = lf.group_by("region").agg(nw.col("amount").sum())
df = agg.collect(backend="polars")  # Narwhals DataFrame backed by Polars
print(df.to_native())

Without an explicit backend, collect() picks a default per source: DuckDB and PySpark materialize to PyArrow, Dask to pandas, Polars to Polars.

Use it only as an ingestion layeringest-to-one-format

import pandas as pd
import narwhals as nw
from narwhals.typing import IntoDataFrame


def df_to_pandas(df_native: IntoDataFrame) -> pd.DataFrame:
    return nw.from_native(df_native).to_pandas()

A valid way to adopt it: accept anything, convert once, keep your internals pandas. You avoid special-casing to_pandas, toPandas, and df across libraries.

Read column names and dtypes portablyinspect-schema

frame = nw.from_native(df_native)

print(frame.columns)
schema = frame.collect_schema()
print(schema["amount"] == nw.Float64)

numeric_cols = [name for name, dtype in schema.items() if dtype.is_numeric()]

Dtypes are Narwhals types (nw.Int64, nw.String), not the backend's, which is what makes comparisons portable. On lazy backends, reading the schema can trigger a metadata query.

Select columns with selectorsselect-by-dtype

import narwhals as nw
import narwhals.selectors as ncs

scaled = (
    nw.from_native(df_native)
    .with_columns(ncs.numeric() * 100)
    .select(ncs.numeric() | ncs.matches("^id_"))
    .to_native()
)

Selectors combine with &, |, -, and ~ like Polars. They are the portable replacement for pandas select_dtypes.

Branch on the underlying library when you mustdetect-backend

from narwhals.dependencies import is_pandas_dataframe, is_polars_dataframe

if is_pandas_dataframe(df_native):
    ...  # pandas-specific fast path

# or, from a wrapped frame:
ns = nw.get_native_namespace(nw.from_native(df_native))
native_df = nw.from_native(df_native).to_native()

These helpers check without importing the library, so a pandas-free environment does not blow up on the import. Reach for the escape hatch sparingly; every branch is a code path you now test twice.

Create frames and series for a chosen backendconstruct-frames

df = nw.from_dict(
    {"a": [1, 2, 3], "b": [4.0, 5.0, 6.0]},
    backend="polars",
)
s = nw.new_series("c", [7, 8, 9], backend="pandas")

print(df.to_native(), s.to_native())

Constructors need an explicit backend because Narwhals brings none of its own. In a library, pass the caller's namespace through instead of hardcoding one.

Alternatives

PackageRegistryPick it when
ibis-frameworkPyPIYour backends are databases and engines rather than in-memory frames, and you want expressions compiled to SQL
polarsPyPIYou control the code end to end and can simply require one fast dataframe library instead of supporting many
pandasPyPIYour users all bring pandas anyway and the compatibility layer would be pure overhead