How to Use Narwhals: The Beginner's Guide
CodexFuse Team · 2026-11-02T13:13:45.918Z · 14 min read min read
Narwhals is the lightweight Python compatibility layer that wraps any dataframe library in one stable API. This beginner guide covers installing Narwhals, wrapping frames with from_native, and writing portable dataframe code.
How to Use Narwhals If You Write Dataframe Code
Learning how to use Narwhals takes about fifteen minutes, and the payoff is simple: you stop rewriting the same dataframe logic for every library. Narwhals is, in its own words, an "extremely lightweight and extensible compatibility layer between dataframe libraries." You write your code once against the Narwhals API, wrap any incoming frame with from_native, and the same code runs on pandas, Polars, PyArrow, Modin, or cuDF — returning the original flavor when you call to_native.
One distinction matters before anything else: Narwhals is an adapter, not an engine. It adds no compute of its own — it forwards your calls to the dataframe library you passed in. That is the opposite of Ibis, which is a full expression API that compiles your Python to backend queries; Narwhals versus Ibis is thin adapter versus full expression engine, and they are never rivals. If you want the engine side of that comparison, our Ibis beginner guide covers it.
This guide is written for Python data folks who already know at least one dataframe library. Every command below was verified on 2026-11-02 against Narwhals 2.28.0, the current PyPI release, including a real run of the same function on pandas and Polars with real output.
What Is Narwhals, Exactly?
Narwhals is a zero-dependency Python library that lets you write dataframe-agnostic code: one function written against the Narwhals API that accepts a pandas DataFrame, a Polars DataFrame or LazyFrame, a PyArrow Table, a Modin frame, or a cuDF frame, and runs natively on whichever one it receives. The vendor's own three-step recipe is the whole product: use narwhals.from_native to wrap a native frame, use the Polars-style API Narwhals supports, and use narwhals.to_native to hand the result back in its original flavor — start with pandas, get pandas back; start with Polars, get Polars back.
A Narwhals Tutorial in 5 Steps: How Do You Use Narwhals?
Here is the complete beginner arc. Do it once in a terminal and you understand the product.
- Install Narwhals with
pip install narwhals— the recommended route, and the most up-to-date. It requires Python 3.10 or newer and pulls in zero dependencies. - Write an agnostic function using
narwhals as nwand expressions likenw.col("score") > 80, exactly as you would write Polars. - Wrap the input with
nw.from_native(df)so your function accepts any supported frame type. - Compute with
.filter(),.with_columns(),.select(), and.sort()— all forwarded to the native library underneath. - Unwrap the result with
nw.to_native()so your caller gets their original frame flavor back.
How Do You Install Narwhals for Python?
Narwhals installs the way most Python packages do, and the install itself is the first proof of its philosophy: nothing else comes along for the ride. The narwhals python install below works in any Python 3.10 or newer environment and was verified on 2026-11-02.
python -m pip install narwhals # or, per your toolchain: # conda install -c conda-forge narwhals # uv add narwhals # poetry add narwhals
| Route | Command | Best for |
|---|---|---|
| pip (recommended) | pip install narwhals | Always the most up-to-date release |
| venv + pip | python -m pip install narwhals | Isolated project environments |
| conda | conda install -c conda-forge narwhals | Conda-managed data stacks |
| uv | uv add narwhals | Fast modern Python workflows |
| Optional backend extras | python -m pip install "narwhals[polars,pyarrow]" | Convenience pins when YOU are the end user |
Install it into the same environment as your notebook — our Jupyter beginner guide walks through environment setup. Keep the project in version control from the start with our Git beginner guide, and if you need a reproducible sandbox for readers of your code, our Docker beginner guide covers that.
One install rule is load-bearing for library authors: do not force backend extras on your users. The docs explicitly warn that pinning narwhals[polars,pyarrow] as a library dependency forces every consumer to install Polars and PyArrow, which defeats Narwhals' "support all, depend on none" design. Depend on bare narwhals and let each user bring their own frame library.
How Does narwhals from_native Actually Work?
nw.from_native is the whole trick. It takes a native frame — a pandas.DataFrame, a polars.DataFrame, a pyarrow.Table — and returns it wrapped in a Narwhals class that speaks one consistent API. Underneath, your call is forwarded through a compliant wrapper (PandasLikeDataFrame, ArrowDataFrame, or PolarsDataFrame) straight to the native library, so compute stays native to whatever you passed in.
The unit of work is the expression. Narwhals' own theory page defines it precisely: an expression is a function from a DataFrame to a sequence of Series. nw.col("a") means "given a dataframe, give me the Series 'a' from it" — it produces no value until you hand it to a frame context like .select(), .with_columns(), or .filter().
This is a real run, executed for this article on Narwhals 2.28.0 — the same function, one pandas frame and one Polars frame:
import narwhals as nw
import pandas as pd
import polars as pl
def high_scorers(df):
frame = nw.from_native(df)
result = (
frame
.filter(nw.col("score") > 80)
.with_columns(doubled=nw.col("score") * 2)
.sort("name")
)
return nw.to_native(result)
data = {"name": ["amy", "bob", "cat"], "score": [91, 75, 88]}
high_scorers(pd.DataFrame(data))
# name score doubled
# 0 amy 91 182
# 2 cat 88 176
high_scorers(pl.DataFrame(data))
# shape: (2, 3)
# amy 91 182
# cat 88 176
Bob's 75 is filtered out by both backends, the doubling is computed by both, and each caller gets its own frame type back. That round-trip — wrap, compute, unwrap — is the entire mental model.
For library code you can go one step further with the @nw.narwhalify decorator, which auto-wraps the frame argument and auto-unwraps the return value, so your function body stays pure Narwhals while its signature stays native.
How Do You Run the Same Narwhals Code on Pandas and Polars?
You just saw it: the high_scorers function above ran unchanged on both. The narwhals polars pairing is the most common in the wild — Polars users get a stable cross-library API for free, and pandas users get the same code with zero rewrites. This is the canonical beginner pattern from Narwhals' own docs, reduced to its simplest form:
def agnostic_get_columns(df_native):
df = nw.from_native(df_native)
return df.columns
agnostic_get_columns(pd.DataFrame({"a": [1], "b": [2]})) # ['a', 'b']
agnostic_get_columns(pl.DataFrame({"a": [1], "b": [2]})) # ['a', 'b']
agnostic_get_columns(pa.table({"a": [1], "b": [2]})) # ['a', 'b']
The docs call this "the simplest possible example of a dataframe-agnostic function," and the README makes the portability promise concrete: pass a pandas DataFrame, a polars DataFrame, a polars LazyFrame, a duckdb relation, a PySpark DataFrame, or a PyArrow Table, and in each case no additional dependencies are required and computation stays native to the input library.
Which Dataframe Libraries Does Narwhals Support?
Narwhals splits its backends into two tiers, quoted here from the project's README:
| Tier | Backends | What it means |
|---|---|---|
| Full API support | cuDF, Modin, pandas, Polars, PyArrow | The complete Narwhals API works on eager frames from these libraries |
| Lazy-only support | Dask, DuckDB, Ibis, PySpark, SQLFrame, Daft (via plugin), Apache DataFusion (via plugin) | Supported through the Narwhals LazyFrame API on native lazy objects |
The LazyFrame is, per the API reference, a "Narwhals LazyFrame, backed by a native lazyframe" — you never instantiate it directly, you get one by calling narwhals.from_native on a native lazy object such as a polars.LazyFrame, a Dask dataframe, or a duckdb.DuckDBPyRelation. This is also where read and scan helpers live: read_* and scan_* functions require an explicit backend= argument because Narwhals will not guess the engine for you.
Narwhals vs Polars: What's the Difference?
This is the most common confusion, so say it once and plainly: Polars is a single dataframe engine you pin as a dependency; Narwhals is the compatibility layer that can wrap Polars — and pandas, and PyArrow — so your code does not have to pin any of them.
| Question | Narwhals | Polars |
|---|---|---|
| What is it? | Adapter over existing frame libraries | A dataframe engine with its own Rust core |
| Does it compute? | No — forwards to the wrapped library | Yes — native, multithreaded execution |
| API to learn | A subset of the Polars API | The full Polars API |
| Who is it for? | Library authors targeting many backends | Anyone who standardizes on Polars |
| Dependency cost | Zero — uses what you pass in | You pin and ship Polars itself |
If every user of your code already runs Polars, use Polars directly. Narwhals earns its place when your users' frame libraries are out of your control.
Narwhals vs Ibis vs Fugue: Which Portable Layer Fits?
The portable-dataframe family has three distinct members, and the queue's own sibling notes draw the lines this way. Ibis is the "compile once, run anywhere" expression API: you write table expressions and Ibis compiles them for 20-plus backends, executing through the backend's own engine. Narwhals is the thin adapter: no compilation, no engine, just your logic forwarded to the frame you were handed. Fugue is the held distributed sibling — same write-once instinct, aimed at distributed execution.
Pick Narwhals when you ship a library and the user brings the frame. Pick Ibis when you own the pipeline and want one expression language across warehouses. An independent comparison piece at codecut.ai walks the Ibis-Narwhals-Fugue triangle in more depth and is listed in Sources.
7 Beginner Mistakes That Break Narwhals Code
| Mistake | What happens | The fix |
|---|---|---|
Calling to_native and continuing to use Narwhals methods | Attribute errors — you are back in native-land | Unwrap last, at the boundary of your function |
Forcing narwhals[polars] as a library dependency | Every consumer installs Polars needlessly | Depend on bare narwhals; users bring backends |
| Using the full Polars API through Narwhals | Only the supported subset is guaranteed | Stay inside the Narwhals API surface |
Instantiating LazyFrame directly | Not a supported construction path | Get it via from_native on a native lazy object |
Calling read_csv without backend= | Narwhals refuses to guess the engine | Always pass backend= to read_*/scan_* |
| Prototyping against the moving main namespace forever | Future releases may change function signatures | Ship library code on narwhals.stable.v2 |
| Expecting Narwhals to speed up pandas | It is a compatibility layer, not an accelerator | Expect negligible overhead, not a speedup — the theory page notes Narwhals-guarded pandas code is often simply written better |
Who Should Use Narwhals — and Who Should Skip It?
Narwhals says it plainly on its back-compat page: it is "primarily aimed at library maintainers rather than end users." If you maintain a plotting library, a statistics package, or a data-validation tool and your users arrive with pandas, Polars, Modin, or cuDF, Narwhals lets you support all of them without depending on any of them. The stable-API promise is Rust-editions-inspired: code written against narwhals.stable.v1 or narwhals.stable.v2 is promised to never have its public functions changed or removed, so prototype with import narwhals as nw and switch to import narwhals.stable.v2 as nw when you are happy with the result.
Skip it if your whole team already standardizes on one frame library, or if your analytics live in SQL rather than Python — SQL-first teams should read our dbt beginner guide instead. And if what you actually need is a query engine that targets warehouses, that is the Ibis-shaped problem again, not the Narwhals-shaped one.
Sources
Every fact in this guide was fetched or executed on 2026-11-02. Named sources, in order of use:
- Narwhals installation and quick start — narwhals-dev.github.io/narwhals/installation/ (pip/conda/uv/poetry commands, the agnostic-columns example, the extras warning)
- Narwhals "how it works" — narwhals-dev.github.io/narwhals/how_it_works/ (expression definition, wrapper-layer architecture)
- Narwhals back-compat — narwhals-dev.github.io/narwhals/backcompat/ (library-maintainer framing, stable.v1/v2 Rust-editions promise, @narwhalify)
- Narwhals LazyFrame API reference — narwhals-dev.github.io/narwhals/api-reference/lazyframe/ (backed-by-native-lazyframe wording)
- Narwhals on PyPI — pypi.org/project/narwhals/ (release 2.28.0, uploaded Oct 27, 2026; "Extremely lightweight and extensible compatibility layer")
- Narwhals README — raw.githubusercontent.com/narwhals-dev/narwhals/main/README.md (zero-dependencies checklist, full-API vs lazy-only backend tiers, three-step recipe, whale-categorization example, "100% independent, community-driven")
- Narwhals cheatsheet — blog.thecoatlessprofessor.com/cheatsheets/narwhals.pdf (independent reference, updated 05/27/2026)
- "Narwhals: Unified DataFrame Functions for pandas, Polars, and PySpark" — codecut.ai (independent how-to walkthrough, ~155 days, recently recrawled)
- "Ibis vs Narwhals vs Fugue: DataFrame Portability" — codecut.ai (comparison angle for the portable family)
- Author's own verification this run — pip installed narwhals 2.28.0 in a clean prefix and executed the from_native/filter/with_columns/sort/to_native example on real pandas and Polars frames; outputs quoted verbatim above
Frequently Asked Questions
Is Narwhals a replacement for pandas or Polars?
No. Narwhals wraps pandas, Polars, and friends — it never replaces them. You still need at least one real dataframe library installed, because Narwhals ships zero dependencies and only ever uses the frame you pass in. Think of it as a universal remote, not a new television.
Does Narwhals work with lazy frames like Polars LazyFrame or Dask?
Yes, through the Narwhals LazyFrame API. Wrap a native lazy object — a polars.LazyFrame, a Dask dataframe, a DuckDB relation — with from_native and you get a Narwhals LazyFrame backed by it. Lazy-only backends in the README's second tier (Dask, DuckDB, Ibis, PySpark, SQLFrame, and the Daft/DataFusion plugins) work this way.
What is the difference between Narwhals and Ibis?
Narwhals is a thin adapter: it forwards your dataframe calls to whatever frame you handed it, adding no compute. Ibis is a full expression API that compiles your Python into backend queries across 20-plus engines. Adapter versus engine — different layers, never rivals.
Is Narwhals stable enough for library code?
That is exactly its target audience. Beyond the current 2.28.0 release, the project promises that code written against narwhals.stable.v1 or narwhals.stable.v2 will never have its public functions changed or removed, and it will maintain both stable namespaces indefinitely. Prototype on the main namespace, ship on a stable one.
Do I need to learn a new API to use Narwhals?
Effectively no. Narwhals implements a subset of the Polars API, so if you know Polars expressions — nw.col, .filter, .with_columns — you already know Narwhals. The new concepts are just the wrap/compute/unwrap rhythm and the stable-namespace convention.
