Data Analysis with Python (pandas, polars, DuckDB) Module 12 – polars: expressions, lazy queries and speed
pandas to polars and back: migration and interop
Port pandas code to polars with an idiom map, convert frames both ways through Arrow, run SQL on polars frames and prove a port with a frame-equality test.
What you will learn
- Translate common pandas idioms to polars and spot those with no direct equivalent
- Convert between pandas, polars and Arrow, and state what each conversion changes
- Run SQL on polars frames with pl.sql and SQLContext
- Decide which library a given team and task should use
Before you start
What you’ll learn · Before you start
What you will learn
- Translate common pandas idioms to polars and spot those with no direct equivalent
- Convert between pandas, polars and Arrow, and state what each conversion changes
- Run SQL on polars frames with pl.sql and SQLContext
- Decide which library a given team and task should use
Before you start
On this page
On a fifteen-line shop export, moving code from pandas to polars and back means translating idioms, converting frames, and proving that the result
did not change. Most pandas operations have a direct polars method: a boolean mask becomes filter, assign becomes
with_columns, groupby().agg becomes group_by().agg, transform becomes an expression with over(), merge
becomes join and melt becomes unpivot. Code that relies on the index, such as set_index, loc or alignment by
label, has no direct equivalent, because polars has no index: it becomes key columns, filters and joins. Frames cross through an Arrow interchange boundary: A transfer of typed column data between libraries using Arrow representations; values, nulls, types and key semantics still need a contract., with pl.from_pandas one way and to_pandas back: to_pandas needs the
pyarrow package, and so does from_pandas for any column that is not a plain NumPy one, such as text. pl.sql runs SQL over polars frames, and frame equivalence: Agreement on the intended rows, values, column order, row order and required types, tested under an explicit comparison policy. is checked with assert_frame_equal under an explicit value, order and type policy.
Port choices expose index and missing-value rules
The examples use 15 order lines of a clothing shop in sales.csv, with the usual flaws of an export: city names in
mixed case with stray spaces, a missing city, a missing status, a repeated order and missing discounts.
| pandas | Details |
|---|---|
df[df["qty"] > 1] |
polars: df.filter(pl.col("qty") > 1); What to watch: A null condition drops the row |
df[["city", "qty"]] |
polars: df.select("city", "qty"); What to watch: |
df.assign(amount=...) |
polars: df.with_columns(amount=...); What to watch: |
df["amount"] = ... |
polars: df = df.with_columns(...); What to watch: Nothing changes in place |
np.where(cond, a, b) |
polars: pl.when(cond).then(a).otherwise(b); What to watch: Strings in then are column names |
df.groupby("city").agg(...) |
polars: df.group_by("city").agg(...); What to watch: Groups unsorted; a null key kept |
groupby(...).transform("sum") |
polars: pl.col("x").sum().over("city"); What to watch: |
df.merge(other, on="k") |
polars: df.join(other, on="k"); What to watch: Null keys never match |
melt, pivot_table |
polars: unpivot, pivot; What to watch: |
fillna, isna |
polars: fill_null, is_null; What to watch: NaN is a separate value |
drop_duplicates() |
polars: unique(keep="first", maintain_order=True); What to watch: The default keeps any row |
df.apply(f, axis=1) |
polars: An expression; map_elements only as a last resort; What to watch: Python per row is slow |
set_index, loc, reset_index |
polars: A key column with filter and join; What to watch: There is no index |
Most of a port from pandas to polars is mechanical: one line of pandas becomes one step of a polars chain, and the
chain reads top to bottom. Three behaviours change results rather than syntax, and the right-hand column of the table
names them. A missing value in a condition counts as “not true”, so filter drops the row where the original object-valued pandas examples keep
it. Nullable pandas Boolean masks can drop unknown conditions too; state the dtype and policy. A missing group key forms a group of its own, where pandas leaves it out. And polars keeps no group or join order
unless it is asked to, so a result needs an explicit sort before it is compared or published.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- Translate the operation's meaning, not just its spelling. A mask becomes a filter and key matching becomes an explicit join.
- Polars and pandas defaults can differ on missing conditions and group keys. Decide those policies in the port.
- A pandas row index with business meaning becomes a Polars column; lookups and alignment use filters and joins.
Checkpoint
Check: idiom map
Conversion preserves values while types and index can change
What changes at the boundary between pandas and polars
Text description of the diagram
A pandas DataFrame has an index and marks missing values with NaN or pd.NA; its integers are int64, or the nullable Int64 when values are missing. An Apache Arrow table has typed columns and one null marker for every type. A polars DataFrame has no index and uses null everywhere.
- pl.from_pandas goes from pandas to polars through Arrow. NaN in a float column becomes null, and the index is dropped unless include_index=True. Numeric columns without missing values are taken over without being copied.
- to_pandas() goes back, through Arrow again. An Int64 column that contains a null comes back as float64, with NaN for the null, unless use_pyarrow_extension_array=True keeps the Arrow types.
- The pandas DataFrame that comes back has a new index, numbered 0, 1, 2 and so on.
import pandas as pd
import polars as pl
# A pandas frame with the types an analyst meets: a nullable integer, a float with NaN, text, a category, a timestamp.
pdf = pd.DataFrame({
"qty": pd.array([2, None, 5], dtype="Int64"),
"price": [899.0, float("nan"), 349.0],
"city": ["Pune", None, "Thane"],
"kind": pd.Categorical(["kurta", "saree", "kurta"]),
"at": pd.to_datetime(["2026-04-02 10:00", "2026-04-05 11:30", None]),
}, index=pd.Index(["a", "b", "c"], name="row"))
print("pandas:", {c: str(t) for c, t in pdf.dtypes.items()})
# pandas to polars, through Arrow: NaN becomes null, and the index is dropped unless you ask for it.
p = pl.from_pandas(pdf)
print("polars:", p.schema)
print("with the index:", pl.from_pandas(pdf, include_index=True).columns)
# polars to pandas: an integer column with a null comes back as float64 unless Arrow-backed types are asked for.
print("back: ", {c: str(t) for c, t in p.to_pandas().dtypes.items()})
print("arrow: ", {c: str(t) for c, t in p.to_pandas(use_pyarrow_extension_array=True).dtypes.items()})
print("qty after the round trip:", p.to_pandas()["qty"].tolist()) Output
pandas: {'qty': 'Int64', 'price': 'float64', 'city': 'str', 'kind': 'category', 'at': 'datetime64[us]'}
polars: Schema({'qty': Int64, 'price': Float64, 'city': String, 'kind': Categorical, 'at': Datetime(time_unit='us', time_zone=None)})
with the index: ['row', 'qty', 'price', 'city', 'kind', 'at']
back: {'qty': 'float64', 'price': 'float64', 'city': 'str', 'kind': 'category', 'at': 'datetime64[us]'}
arrow: {'qty': 'int64[pyarrow]', 'price': 'double[pyarrow]', 'city': 'large_string[pyarrow]', 'kind': 'dictionary<values=large_string, indices=int64, ordered=0>[pyarrow]', 'at': 'timestamp[us][pyarrow]'}
qty after the round trip: [2.0, nan, 5.0]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 convert.py
pl.from_pandas turns NaN in a float column into null, keeps the nullable Int64, maps pandas’ str to String
and a category to Categorical, and drops the index unless include_index=True keeps it as a column. The way back
changes one type silently: an integer column with a null comes back as float64, with NaN in place of the null,
because NumPy integers cannot be missing. to_pandas(use_pyarrow_extension_array=True) preserves Arrow-backed representations and nulls for these
columns; inspect the resulting schema rather than assuming every dtype is identical. Convert once, at the edge of a pipeline, and say in the code which types you expect back.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original frame contains an Int64 quantity column with a missing middle value.
- The default conversion uses a floating-point representation for this nullable integer column.
- Requesting Arrow-backed pandas columns preserves the integer representation and null in this example. Index values must be kept separately if needed.
Checkpoint
Check: frame conversion
SQL and expressions query the same in-scope frames
import polars as pl
sales = pl.read_csv("sales.csv").with_columns(
pl.col("city").str.strip_chars().str.to_titlecase(),
amount=pl.col("qty") * pl.col("unit_price"),
)
# pl.sql finds polars (and pandas) frames by their variable name; it returns a LazyFrame unless eager=True.
by_sql = pl.sql("""
SELECT city, COUNT(*) AS orders, SUM(amount) AS revenue
FROM sales
WHERE status = 'Delivered' AND city IS NOT NULL
GROUP BY city
ORDER BY revenue DESC
""").collect()
# The same query as expressions.
by_expr = (
sales.filter((pl.col("status") == "Delivered") & pl.col("city").is_not_null())
.group_by("city")
.agg(orders=pl.len(), revenue=pl.col("amount").sum())
.sort("revenue", descending=True)
)
print(by_sql)
print("same values as the expressions:", by_sql.equals(by_expr.cast({"orders": pl.Int64})))
# SQLContext registers frames under names of your choice.
ctx = pl.SQLContext(orders=sales, eager=True)
print(ctx.tables(), ctx.execute("SELECT product, SUM(qty) AS units FROM orders GROUP BY product ORDER BY product").rows()) Output
shape: (3, 3)
┌────────┬────────┬─────────┐
│ city ┆ orders ┆ revenue │
│ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ i64 │
╞════════╪════════╪═════════╡
│ Mumbai ┆ 3 ┆ 8742 │
│ Pune ┆ 3 ┆ 6796 │
│ Nagpur ┆ 4 ┆ 6444 │
└────────┴────────┴─────────┘
same values as the expressions: True
['orders'] [('Dupatta', 10), ('Kurta', 12), ('Saree', 5)]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sql_on_frames.py
pl.sql finds polars and pandas frames by their variable names and returns a LazyFrame unless eager=True asks for
a DataFrame. SQLContext registers frames under names of your choice, which keeps larger programs readable. Both run
through the same engine as expressions, so the SQL report and the expression report give the same values; only
COUNT(*) comes back as Int64 where pl.len() gives UInt32, which the comparison casts away. SQL is often the
shortest path when the code being ported was itself SQL, or when the people who maintain it think in SQL.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- pl.sql resolves the registered or visible frame name used in the query. It is not an automatic request to open sales.csv.
- The original SQL and expression versions report Mumbai, Pune and Nagpur after excluding the missing city.
- The values agree after the original explicit count-column cast. Shared query execution does not imply identical output dtypes.
Checkpoint
Check: sql frames
Frame equality detects the missing city revenue
compare_reports.py
import pandas as pd
import polars as pl
from polars.testing import assert_frame_equal
import report_pandas
import report_polars
from_pandas = pl.from_pandas(report_pandas.report("sales.csv"))
from_polars = report_polars.report("sales.csv")
print(from_pandas)
print(from_polars)
try:
assert_frame_equal(from_pandas, from_polars, check_dtypes=False)
print("the two reports agree")
except AssertionError as e:
print("AssertionError:", str(e).splitlines()[0]) Output
shape: (3, 3) ┌────────┬────────┬─────────┐ │ city ┆ orders ┆ revenue │ │ --- ┆ --- ┆ --- │ │ str ┆ i64 ┆ i64 │ ╞════════╪════════╪═════════╡ │ Mumbai ┆ 3 ┆ 8742 │ │ Nagpur ┆ 4 ┆ 6444 │ │ Pune ┆ 3 ┆ 6796 │ └────────┴────────┴─────────┘ shape: (4, 3) ┌────────┬────────┬─────────┐ │ city ┆ orders ┆ revenue │ │ --- ┆ --- ┆ --- │ │ str ┆ u32 ┆ i64 │ ╞════════╪════════╪═════════╡ │ Mumbai ┆ 3 ┆ 8742 │ │ Nagpur ┆ 4 ┆ 6444 │ │ Pune ┆ 3 ┆ 6796 │ │ null ┆ 1 ┆ 1396 │ └────────┴────────┴─────────┘ AssertionError: DataFrames are different (height (row count) mismatch)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 compare_reports.py
report_pandas.py
import pandas as pd
def report(path):
df = pd.read_csv(path)
df["city"] = df["city"].str.strip().str.title()
df = df[df["status"] == "Delivered"]
df = df.assign(amount=df["qty"] * df["unit_price"])
return df.groupby("city", as_index=False).agg(orders=("order_id", "size"), revenue=("amount", "sum")).sort_values("city", ignore_index=True)
if __name__ == "__main__":
print(report("sales.csv")) report_polars.py
import polars as pl
def report(path):
return (
pl.read_csv(path)
.with_columns(pl.col("city").str.strip_chars().str.to_titlecase())
.filter(pl.col("status") == "Delivered")
.with_columns(amount=pl.col("qty") * pl.col("unit_price"))
.group_by("city")
.agg(orders=pl.len(), revenue=pl.col("amount").sum())
.sort("city", nulls_last=True)
)
if __name__ == "__main__":
print(report("sales.csv")) assert_frame_equal compares two polars frames column by column and stops at the first difference;
check_dtypes=False lets integer widths differ, since pandas counts in int64 and polars in UInt32. The test fails,
and it is right to: the polars report has a fourth row, the order with no city, which pandas’ group-by left out. Fill
the city with “Unknown” in both versions, or pass dropna=False to pandas, and the test passes. Without the test, the
two reports would have disagreed by 1,396 rupees in silence.
Which library a team should use depends on the work more than on speed. pandas has the widest ecosystem and suits small and medium tables that feed plotting, statistics or machine-learning libraries. polars suits long pipelines of expressions on large tables and lazy reads of big files. DuckDB suits work that is mostly SQL or mostly files on disk. The three exchange data through Arrow, so mixing them is normal.
Follow the input and compare the result with the recorded example.
The drawing shows the whole scene; its 3 steps are listed below.
Every step as text
- The original pandas group-by excludes the missing city and reports three rows.
- The Polars result retains that city group, so frame equality correctly detects a height mismatch.
- Naming the city consistently or choosing pandas dropna=False aligns the report's group policy. Compare rows, values, order and required types.
Checkpoint
Check: port proof
A 12-step cleaning script ported and proved, with the traps reproduced Pro: opens with a Pro, Premium or Ultimate pass
A real port is longer than a report, and every step is a chance for the two versions to drift apart. This part ports a 12-step pandas cleaning script to one polars chain, proves the result equal with a test, reproduces the four differences that most often break a first port, and leaves you a port of your own to finish.
In this part:
- Explicit null and duplicate policies make ports agree
- Four library defaults can change a report
- A port preserves duplicates, filters, totals and types
3 diagrams · 5 runnable examples (CSV, Python, clean_pandas.py, clean_polars.py, compare_ports.py) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
What crosses the boundary between pandas and polars, and at what cost Pro: opens with a Pro, Premium or Ultimate pass
Converting a frame is cheap for some columns and costly for others, and it changes some types on the way back. This part times a million-row conversion column type by column type, shows which columns are not copied at all, and sets out a table of the types that survive the round trip.
In this part:
- Column types decide measured conversion work
- Nulls and row labels need a boundary contract
- Arrow interchange still requires type checks
3 diagrams · 2 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Where polars SQL differs, old code tested, and the choice of library Pro: opens with a Pro, Premium or Ultimate pass
SQL on polars frames is convenient, and it follows SQL’s rules rather than those of expressions in a few places that change numbers. This part reproduces those differences, tests suggested or older polars code with deprecation warnings turned into errors, and gives a rubric for choosing pandas, polars or DuckDB for a team.
In this part:
- SQL sums and division state their intended meaning
- Warnings as errors expose old API spellings
- A measured pilot chooses where to migrate
3 diagrams · 2 runnable examples (Python) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Practise the stated transformations
Practice that adapts to you
Practise this lesson
11 questions about 12 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
-
Fresher to mid · Warm-up
polars has no index. How do you do in polars what pandas does with set_index, loc and reset_index?
In pandas the index labels rows, so set_index("customer") followed by df.loc["C01"] looks a row up by its label, and reset_index turns the labels back into a column. In polars the key stays an ordinary column: df.filter(pl.col("customer") == "C01") finds the row, and there is nothing to reset. Alignment by label, where pandas matches the rows of two Series by their index, becomes an explicit join on the key column, and a MultiIndex becomes several key columns used in group_by, join or pivot. Access by position, such as iloc, becomes row(i), slice or head. The code gets a little longer, but every match on a key is written out, so nothing aligns by accident.
-
Mid to senior
A pipeline converts a pandas frame to polars, cleans it, and converts it back with to_pandas(). Afterwards a database load fails because the quantity column is now a float, and a lookup by customer id fails because the index is gone. Explain both failures and design the boundary.
-
Senior to staff
Your team runs 40 pandas scripts in production, a few of them slow. A manager asks you to move all of them to polars. How do you decide what to migrate, and how do you reduce the risk of the migration?
-
Mid to senior
After a polars upgrade, a report written with pl.sql shows null instead of 0 refunds for cities that had none, and a ratio of two integer columns changed from 3 to 3.5 on some rows. Explain both changes and how you protect SQL-based reports.
Key takeaways
- Most pandas idioms map to one polars method; code built on the index becomes key columns, filters and joins.
pl.from_pandasandto_pandasgo through Arrow:NaNbecomes null on the way in, and an integer column with a null becomesfloat64on the way back unless Arrow types are kept.pl.sqlandSQLContextrun SQL over polars frames with the same engine as expressions.- Prove every port with
assert_frame_equalon real inputs, and sort before comparing.
Terms in this lesson
- Arrow interchange boundary
- A transfer of typed column data between libraries using Arrow representations; values, nulls, types and key semantics still need a contract.
- frame equivalence
- Agreement on the intended rows, values, column order, row order and required types, tested under an explicit comparison policy.
References
- Coming from pandas (Polars user guide) (Polars)
- Arrow producer and consumer (Polars user guide) (Polars)
- SQL introduction (Polars user guide) (Polars)
- Generating Polars code with LLMs (Polars user guide) (Polars)
- polars.testing.assert_frame_equal (Python API reference) (Polars)
- polars.DataFrame.join (Python API reference) (Polars)
- Upgrading to version 2.0 (Polars user guide) (Polars)
- PyArrow integration (pandas user guide) (pandas development team (NumFOCUS))
- Comparison with SQL (pandas documentation) (pandas development team (NumFOCUS))
- Rounding functions (DuckDB documentation) (DuckDB Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress