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.
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.
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
- You are writing an application, not a library: if your code only ever sees one dataframe type, calling pandas or Polars directly is simpler and easier for the next person to read
- You need the whole Polars API: Narwhals implements a subset, coverage differs per backend (they publish an api-completeness page for exactly this reason), and anything missing means dropping back to native objects mid-pipeline
- Your logic depends on eager tricks: lazy-only backends like DuckDB, PySpark, Dask, Ibis, and Daft expose only the LazyFrame subset, so no shape, no row indexing, and no peeking at values without a collect
- You expect a speed-up: Narwhals translates syntax, it does not change engines, so pandas via Narwhals is still pandas with pandas dtypes, pandas null semantics, and pandas performance
- You want quiet dependencies: it releases very often (2.24.0 in July 2026) and only the stable namespaces are frozen, so importing plain narwhals puts you on a moving API
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 unchangedWithout 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
| Package | Registry | Pick it when |
|---|---|---|
| ibis-framework | PyPI | Your backends are databases and engines rather than in-memory frames, and you want expressions compiled to SQL |
| polars | PyPI | You control the code end to end and can simply require one fast dataframe library instead of supporting many |
| pandas | PyPI | Your users all bring pandas anyway and the compatibility layer would be pure overhead |