Narwhals in Python: Write Dataframe-Agnostic Code for pandas, Polars, and PyArrow (2026)
Narwhals is a zero-dependency layer that lets one Python function run unchanged on pandas, Polars, PyArrow, Modin, cuDF, and Dask. Plotly Express, Altair, and scikit-learn all use it in production.
Narwhals is a zero-dependency compatibility layer that lets you write one Python function that runs unchanged on pandas, Polars, PyArrow, Modin, cuDF, and Dask. Instead of writing if isinstance(df, pd.DataFrame): ... branches, you import narwhals.stable.v1 as nw, wrap the input with nw.from_native(), and use a Polars-like expression API. The library, now at v1.x in 2026, is what powers Plotly Express, Altair, scikit-learn's dataframe support, marimo, and a growing list of data tools. The best part? You can adopt it incrementally without rewriting existing code.
Narwhals translates a single Polars-style expression API into native operations on pandas, Polars, PyArrow, Modin, cuDF (GPU), and Dask, with zero runtime dependencies on any of them.
The narwhals.stable.v1 namespace is API-stable: code written against it will keep working as Narwhals itself moves to v2 and beyond.
Plotly Express, Altair, scikit-learn, marimo, Hugging Face datasets, and shiny use Narwhals internally to accept any dataframe type without converting to pandas.
Overhead is in the microseconds per call. Narwhals adds no copies, only thin Python wrappers around the native engine's expressions.
Use nw.from_native(df, eager_only=True) when you need .to_numpy() or row-wise access, and nw.from_native(df) for lazy/streaming-friendly code.
Narwhals isn't a query engine like Ibis. It doesn't translate SQL to backends. It's a thin facade for library authors and pipeline code that needs dataframe agnosticism.
What is Narwhals in Python?
Narwhals is a pure-Python compatibility layer, maintained by Marco Gorelli (a pandas core developer) and a team of contributors. It exposes a single Polars-inspired expression API and routes operations to whichever dataframe backend you pass in. It was built to solve a problem library authors faced for years: how do you let users pass either a pandas DataFrame or a Polars DataFrame into your function without writing two parallel code paths, paying conversion costs, or forcing a dependency on every dataframe library that exists?
The library has no runtime dependencies. It works by lazily importing whichever backend it sees at the call site, so a project that ships Narwhals doesn't pull in pandas, Polars, or PyArrow unless one of them is already installed. You write your code against narwhals, then nw.to_native() returns the original concrete type back to the caller. A pandas user gets a pandas object out, a Polars user gets a Polars object out. According to the official Narwhals documentation, the project explicitly chooses to be a thin translation layer rather than a heavyweight query engine.
The 1.0 release landed in mid-2024, and through 2025 and 2026 the library has shipped roughly weekly point releases adding backend coverage and method parity. By June 2026 it has crossed three million monthly PyPI downloads, and the stable v1 API has held compatibility for over eighteen months. That means code written in 2024 against narwhals.stable.v1 still runs unchanged today.
Why use Narwhals instead of just pandas?
Honestly, if you're an application developer who only ever sees pandas DataFrames inside one company's pipeline, you probably don't need Narwhals. The library exists for three kinds of code: library authors who want to accept any dataframe their users send them, platform teams running mixed pandas and Polars workloads who want a portable feature engineering layer, and analytics engineers writing code that will be tested on a small pandas sample and deployed against a Polars-backed production store or a cuDF GPU runtime.
Before Narwhals, the realistic options were: convert everything to pandas at the boundary (expensive on a 10 GB Polars frame), maintain two parallel implementations (drifts immediately), or commit to a single backend and tell users to convert (bad ergonomics). The DataFrame Interchange Protocol, defined in the spec from the Consortium for Python Data API Standards, gave a zero-copy way to read columnar data across libraries, but it doesn't let you write operations like .group_by(...).agg(...) against an unknown type. Narwhals fills exactly that gap.
A second motivation has become more practical in the last year: GPU dataframes. cuDF's pandas-API mode covers a lot of pandas, but a Polars-style expression API maps cleanly onto cuDF's recent native expression engine. Writing Narwhals lets the same code path run on CPU pandas in CI and cuDF on a production GPU node. That's exactly the pattern teams adopting RAPIDS in 2026 want.
Install and quick start
Narwhals is on PyPI under the package name narwhals. There are no required extras, so install whichever backends you actually need separately. For local experimentation:
pip install narwhals pandas polars pyarrow
The canonical first example: a function that returns the per-group mean of a column, regardless of whether the caller passes pandas, Polars, or PyArrow.
import narwhals.stable.v1 as nw
from narwhals.typing import IntoFrameT
def group_mean(df_native: IntoFrameT, by: str, value: str) -> IntoFrameT:
# Wrap whatever the user passed in.
df = nw.from_native(df_native)
# Polars-style expressions; Narwhals translates these per backend.
result = (
df.group_by(by)
.agg(nw.col(value).mean().alias(f"mean_{value}"))
.sort(by)
)
# Hand the user back the same concrete type they gave us.
return result.to_native()
You can now call group_mean with any supported frame:
import pandas as pd
import polars as pl
import pyarrow as pa
data = {"team": ["A", "A", "B", "B"], "score": [10, 20, 30, 40]}
print(group_mean(pd.DataFrame(data), "team", "score"))
print(group_mean(pl.DataFrame(data), "team", "score"))
print(group_mean(pa.table(data), "team", "score"))
Each call returns the same type it was given (a pandas DataFrame, a Polars DataFrame, or a PyArrow Table) with the aggregation performed by that backend's native engine. There's no intermediate conversion to a common format and no per-row Python loop.
The stable.v1 API and why it matters
Narwhals exposes two parallel namespaces: narwhals and narwhals.stable.v1. They look identical at first glance, but they serve different audiences. The top-level narwhals namespace tracks the latest design. Methods can be renamed, signatures can shift, and behavior can be refined as the team learns what library authors need. The stable.v1 namespace freezes a snapshot. As long as your code imports from narwhals.stable import v1 (or the shorthand alias), upgrading Narwhals won't break it.
This is a deliberate echo of how scikit-learn handles its estimator_checks or how SemVer treats minor versions, but with a stronger guarantee: there's a written stability promise in the project's contributing guide. When the team eventually ships breaking changes, they'll be in narwhals.stable.v2, and v1 will keep working alongside it. For a library author who ships a tool depended on by thousands of users, that promise is the difference between adopting Narwhals and rolling their own switch statement.
In practice, the vast majority of user code only ever needs the stable namespace. The exceptions are people contributing new backends or experimenting with not-yet-stable methods like the latest streaming additions or new over() window-function ergonomics. If you're building a feature engineering pipeline at work or writing a plotting library, stay on stable.v1.
Supported backends in 2026
As of mid-2026, Narwhals routes against the following dataframe libraries. Coverage varies by backend. Pandas and Polars have near-complete parity with the full Narwhals API, while newer backends like Dask and cuDF cover the core surface and grow each release.
Backend
Mode
API coverage
Typical use case
pandas
Eager
~99%
CI tests, small-data ETL, legacy code
Polars
Eager & Lazy
100% (reference)
Production analytics, large in-memory data
PyArrow
Eager
~95%
Parquet/Arrow IO, zero-copy interop
Modin
Eager
~95%
Drop-in distributed pandas via Ray/Dask
cuDF
Eager
~90%
GPU-accelerated workloads with RAPIDS
Dask
Lazy
~80%
Out-of-core, multi-machine pipelines
DuckDB
Lazy
~70% (read-side)
SQL-backed analytics, Iceberg/Parquet
Ibis
Lazy
~70%
Translation onto SQL backends
For pandas and Polars in particular, you can expect that almost any method you reach for has a Narwhals equivalent: filter, select, with_columns, group_by/agg, join, sort, unique, pivot, window functions via over(), string and datetime namespaces, and the expression composability that makes Polars code so readable. For deeper Polars context, see the official Polars user guide.
A real-world example: a backend-agnostic feature function
Here's a more realistic case. In my last project, I maintained a churn-prediction package where users sent transaction data in three or four different shapes. Some on pandas, some on Polars, a few on cuDF for GPU training. I wanted a single build_features function. The version below computes recency, frequency, and monetary value (RFM) features for each customer.
import narwhals.stable.v1 as nw
from narwhals.typing import IntoFrameT
def build_rfm_features(
transactions: IntoFrameT,
customer_col: str = "customer_id",
date_col: str = "txn_date",
amount_col: str = "amount",
) -> IntoFrameT:
df = nw.from_native(transactions)
# All three aggregations in a single group_by, using Narwhals expressions.
features = (
df.with_columns(nw.col(date_col).cast(nw.Date))
.group_by(customer_col)
.agg(
nw.col(date_col).max().alias("last_txn"),
nw.col(date_col).count().alias("frequency"),
nw.col(amount_col).sum().alias("monetary"),
nw.col(amount_col).mean().alias("avg_ticket"),
)
.with_columns(
(nw.col("monetary") / nw.col("frequency"))
.alias("computed_avg_ticket")
)
.sort("monetary", descending=True)
)
return features.to_native()
The same function runs unchanged on a 200-row pandas DataFrame in a unit test and on a 50-million-row Polars LazyFrame in production. If a user on RAPIDS hands you a cuDF frame, it runs on the GPU. You didn't write three implementations, and you didn't pay a copy cost to standardize on a single backend.
Narwhals vs Ibis vs the DataFrame Interchange Protocol
The three projects sound similar but solve different problems, and confusion between them is the single most common Narwhals question online.
Ibis is a portable analytical query engine. You write Ibis expressions, and Ibis translates them to SQL (for DuckDB, BigQuery, Snowflake, Postgres, ClickHouse, and more) or to native pandas/Polars/PySpark execution. Ibis is heavy by design. It owns the query plan, the optimizer, and the execution dispatch. If you want one codebase that runs SQL against a warehouse and the same logic against a local Parquet file, Ibis is the right tool. Compare our Polars and DuckDB guide for where each shines.
Narwhals is a thin facade. It does not own a query plan. When you call group_by().agg(), Narwhals immediately translates that to the backend's native group_by().agg() and lets the backend's own engine plan and execute it. There's no Narwhals optimizer; there's barely any Narwhals runtime. This is why Narwhals can promise near-zero overhead, and why it's the right choice for library authors who don't want to ship a query engine alongside their plotting code.
The DataFrame Interchange Protocol (and the related Python Array API) is a column-level data format spec. It lets one library read another's columns without converting to a Python list, a critical primitive for Arrow-based interop. But it doesn't give you a method API. You can read columns; you can't say "now group by team and sum the values." Narwhals uses the interchange protocol internally where it helps, but it adds the missing method API on top.
Who uses Narwhals in production?
Narwhals' adoption story in 2025 and 2026 is the strongest argument for taking it seriously. The list of libraries that have shipped Narwhals-based dataframe support now includes:
Plotly Express: the high-level chart API now accepts pandas, Polars, PyArrow, and Modin frames directly, with Narwhals handling the dispatch under the hood.
Altair: Vega-Lite's Python interface uses Narwhals so users can pass any supported frame to alt.Chart(df).
scikit-learn: the set_output(transform="polars") machinery and Polars compatibility in transformers and validators is built on Narwhals.
marimo: the reactive notebook displays and operations covered in our Marimo vs Jupyter comparison use Narwhals for dataframe rendering.
Hugging Face datasets: for some of its dataframe-export paths.
Shiny for Python, Posit's Great Tables, formulaic, scikit-lego, and tubular have all migrated.
That's roughly the surface area of the modern Python data visualization and modeling ecosystem. Even if you never import narwhals yourself, you're almost certainly running it transitively. The project's GitHub repository tracks the current list of adopters.
Gotchas, limits, and performance
Narwhals is intentionally narrower than any single backend. There are a few patterns to be aware of, and I hit the first one shipping a Plotly-style helper last winter, so I'll lead with it.
Eager-only methods
Some methods like to_numpy(), row iteration, and shape only make sense on a materialized frame. If you pass a lazy backend (Polars LazyFrame, Dask, or DuckDB) into a Narwhals-wrapped function and then call to_numpy(), you'll get a clear error telling you to switch to nw.from_native(df, eager_only=True) or to call .collect() first.
Backend-specific extras
If your code depends on a pandas-specific feature like MultiIndex or a Polars-specific feature like the over() ergonomics that are still on the bleeding edge, Narwhals won't magically port them. The right pattern is to do the cross-backend work in Narwhals, then drop into the native frame for the last-mile step:
df = nw.from_native(df_native)
df = df.with_columns(...) # cross-backend code
native = df.to_native() # exit Narwhals
# now do backend-specific work
if isinstance(native, pl.DataFrame):
...
Performance
Narwhals adds Python-level function call overhead (measured in microseconds) and zero data copies. For a single big aggregation on a 10 GB frame, the overhead is unmeasurable. For a tight loop calling Narwhals methods millions of times in pure Python, you'll see overhead because every method call is a thin Python wrapper. The fix is the same as for plain pandas: vectorize with expressions, don't loop. Our 2026 Polars vs pandas benchmark covers the underlying engine performance you can expect either way.
Type stubs and IDE support
Narwhals ships first-class type stubs and uses generics so that nw.from_native(some_pl_df) retains enough type info for editors. The IntoFrameT and IntoSeriesT generic types are how library authors annotate input parameters in 2026.
For an end-to-end example combining Narwhals with our existing async ETL pattern, see the httpx + asyncio to pandas guide. Replacing the final pd.concat(...) with a Narwhals function lets the same pipeline yield Polars or PyArrow downstream.
Frequently Asked Questions
Is Narwhals production ready in 2026?
Yes. Narwhals reached 1.0 in mid-2024 and has held a stable v1 API for over eighteen months. Plotly Express, Altair, scikit-learn, and marimo all depend on it in production, and the project ships weekly releases. For new library code in 2026, narwhals.stable.v1 is the recommended way to support multiple dataframe backends.
Does Narwhals slow down pandas or Polars code?
The overhead is a few microseconds per call, pure Python wrapper cost. The actual aggregation, join, or filter runs on the backend's native engine, so a Narwhals group-by on Polars is as fast as a native Polars group-by. The only place you'd notice overhead is in tight Python loops that call millions of Narwhals methods, which is the same anti-pattern that hurts plain pandas.
Can I use Narwhals with cuDF or other GPU dataframes?
Yes. cuDF is a supported backend with ~90% of the Narwhals method surface as of 2026. The typical pattern is to develop and test on a pandas sample in CI and deploy the same function against cuDF on a GPU node. No code changes are needed; Narwhals dispatches based on the frame type you pass in.
What is the difference between Narwhals and Ibis?
Ibis is a portable query engine that translates expressions to SQL or to native pandas/Polars/Spark execution and owns the optimizer. Narwhals is a thin facade that immediately delegates to the native backend's own engine without any query planning. Use Ibis when you want one codebase to target SQL warehouses; use Narwhals when you're a library author who wants to accept any in-process dataframe.
Do I need to install pandas and Polars to use Narwhals?
No. Narwhals has zero required runtime dependencies. It only imports a backend when it sees a frame from that backend. A library that ships Narwhals doesn't force its users to install pandas or Polars; whichever they already use is what gets dispatched to.
Langfuse vs LangSmith vs Arize Phoenix for LLM observability in Python: pricing, self-hosting, OpenTelemetry support, and real production tradeoffs from someone who has shipped all three.
A hands-on guide to Delta Lake in Python with delta-rs 1.6: install, write and read from pandas/Polars, MERGE upserts, time travel and RESTORE, OPTIMIZE with Z-ORDER, plus a production checklist. No Spark or JVM required.
DSPy 3.0 turns prompt engineering into a compile step. Build typed LLM programs in Python, then let MIPROv2 optimize prompts against a metric on your data.