Polars DataFrames skill for Claude Code
Lazy plans, pushed-down filters, no row loops. Claude writes Polars as lazy plans built from expressions, checks with explain that filters and column selection reach the scan, and collects once, streaming when data outgrows memory. When migrating pandas code it uses a mapping reference, keeps the pandas output as an oracle, and compares frames until null, NaN and ordering differences are resolved.
- name
polars-dataframes - size193 lines · ≈1,700 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes fast, memory-efficient Polars code — LazyFrame scans with predicate and projection pushdown, the expression API, window functions with over, validated joins, group_by_dynamic time series, streaming collection for larger-than-memory data — and migrates pandas code to idiomatic Polars. Use when working with Polars, when pandas is too slow or runs out of memory on large CSV or Parquet files, when converting pandas scripts, or when a Polars query is slow or raises schema or dtype errors. Try asking
- Convert this pandas ETL script to Polars and prove the output is identical
- Aggregate 300 GB of Parquet event files on my laptop with Polars
- This Polars query is slower than expected. Check the plan and fix it
Use it when
- You are writing or reviewing Polars code
- pandas runs out of memory or is too slow on large files
- You are migrating pandas scripts to Polars
- A Polars query raises schema, dtype, or join errors
Skip it when
- The team standard is pandas and the data fits comfortably — use pandas-analysis instead
- You would rather answer the question in SQL over files — use duckdb-analytics instead
How to check the result
explain shows filters and projections pushed into the scan, the query collects once, and assert_frame_equal or a total check against the source or old pandas output passes.
The common mistake it prevents
Calling read_csv and then lazy, which loads the whole file eagerly and throws away the pushdown benefits of scan_csv.
Good Polars code is lazy by default, expresses every transformation as expressions (no Python row loops), lets the optimizer push filters and column selection into the scan, declares schemas where inference is risky, and collects once at the end.
Ground rules
- Start from
pl.scan_*(LazyFrame), notpl.read_*, for anything non-trivial. - Express logic with
pl.col(...)expressions insideselect/with_columns/filter/agg.map_elements(Python UDF) is a last resort; it is single-threaded and opaque. - Collect once. Multiple
.collect()calls on the same plan recompute it. - Polars has no index. Order is not guaranteed after
group_byunlessmaintain_order=Trueor an explicitsort. null(missing) andNaN(float not-a-number) are different;fill_nulldoes not touch NaN.- Check the installed version (
pl.__version__); this skill assumes Polars 1.x.
Workflow
- [ ] 1. Inspect schema and size (scan + collect_schema, head)
- [ ] 2. Build the lazy query with expressions
- [ ] 3. Check the optimized plan (explain)
- [ ] 4. Collect (in-memory or streaming) or sink to Parquet
- [ ] 5. Validate results: counts, nulls, totals
- [ ] 6. Benchmark against the old version if this is a migration
1. Inspect
import polars as pl
lf = pl.scan_parquet("data/events/*.parquet")
print(lf.collect_schema())
print(lf.head(5).collect())
print(lf.select(pl.len()).collect().item())
CSV with explicit types:
lf = pl.scan_csv(
"orders.csv",
schema_overrides={"order_id": pl.String, "customer_id": pl.String, "amount": pl.Float64},
try_parse_dates=True,
null_values=["", "NA", "null"],
)
Convert large, repeatedly read CSVs to Parquet once:
pl.scan_csv("big.csv").sink_parquet("big.parquet").
2. Expressions
result = (
lf
.filter(pl.col("status") != "test")
.with_columns(
pl.col("email").str.strip_chars().str.to_lowercase(),
pl.col("created_at").dt.truncate("1mo").alias("month"),
pl.when(pl.col("amount") >= 100).then(pl.lit("large"))
.when(pl.col("amount") >= 20).then(pl.lit("medium"))
.otherwise(pl.lit("small")).alias("size_band"),
)
.group_by("region", "month")
.agg(
pl.len().alias("orders"),
pl.col("amount").sum().alias("revenue"),
pl.col("customer_id").n_unique().alias("customers"),
pl.col("amount").filter(pl.col("size_band") == "large").sum().alias("large_revenue"),
)
.sort("region", "month")
)
df = result.collect()
Window functions (keep row count, compute per group):
lf.with_columns(
(pl.col("amount") / pl.col("amount").sum().over("customer_id")).alias("share_of_customer"),
pl.col("amount").rank("dense", descending=True).over("region").alias("rank_in_region"),
pl.col("created_at").sort().diff().over("customer_id").alias("gap"), # sort inside the window
)
Time series:
daily = (
lf.sort("ts")
.group_by_dynamic("ts", every="1d", group_by="device_id")
.agg(pl.col("value").mean().alias("avg_value"))
)
rolling = lf.sort("ts").with_columns(
pl.col("value").rolling_mean(window_size=7).over("device_id").alias("value_7")
)
Joins
joined = orders.join(
customers, on="customer_id", how="left",
validate="m:1", # error if customers has duplicate keys
coalesce=True,
)
| Need | Join |
|---|---|
| Enrich, keep all left rows | how="left" |
| Rows with/without a match | how="semi" / how="anti" (no columns added) |
| Nearest earlier timestamp | join_asof(other, on="ts", by="symbol", strategy="backward") (both sorted) |
| Cartesian | how="cross" (check sizes first) |
Key dtypes must match exactly (String vs Int64 raises); cast before joining.
3. Check the plan
print(result.explain()) # optimized plan
Look for the filter and the column projection appearing inside the scan node
(SELECTION / PROJECT n/m columns). If a filter sits above a map_elements or a
non-pushdown-able step, move it earlier.
4. Collect
| Data size vs RAM | Do |
|---|---|
| Comfortably fits | .collect() |
| Larger than memory, aggregations/filters | .collect(engine="streaming") |
| Output is large | .sink_parquet("out.parquet") (streams without materializing) |
| NVIDIA GPU available, big aggregations | .collect(engine="gpu") with the GPU extra installed |
Not every operation supports streaming; unsupported parts fall back to in-memory.
5. Validate
orders = lf.filter(pl.col("status") != "test").select("order_id", "amount").collect()
assert orders["order_id"].is_unique().all(), "duplicate order_id in source"
print(df.null_count()) # nulls per output column
assert abs(df["revenue"].sum() - orders["amount"].sum()) < 1e-6 * max(1.0, orders["amount"].sum())
Compare a headline total with the source system or the old pandas output
(polars.testing.assert_frame_equal for exact migration checks, with check_dtypes=False
when dtype mapping differs intentionally).
6. Migration from pandas
Read references/pandas-to-polars.md for the API mapping and semantic differences before converting a pandas script. Migrate function by function, keep the pandas version as an oracle, and compare outputs.
Interop is cheap when needed: df.to_pandas() (use use_pyarrow_extension_array=True
to avoid copies) and pl.from_pandas(pdf).
Gotchas
pl.read_csvthen.lazy()already loaded everything; scan instead.with_columnsexpressions run in parallel and cannot see each other’s new columns within the same call; chain anotherwith_columnsto use a column just created.pl.col("a").sum().over("g")broadcasts;group_by("g").agg(...)collapses. Pick deliberately.pivotexists only on DataFrame (eager); collect first, or aggregate withgroup_by+ conditional sums for a lazy equivalent.meltwas renamedunpivot.- String to number casts with
strict=True(default) raise on bad values; usecast(pl.Float64, strict=False)and count the resulting nulls. is_inand joins onCategoricalcolumns from different sources can mismatch; preferpl.Enumwith fixed categories or plainString.- Row order after joins and
group_byis not stable unless requested. - Deprecated names from 0.x (
groupby,apply,with_column,dtypes=) fail on 1.x.
Output
**Query:** monthly revenue by region from data/events/*.parquet (2.1B rows, 48 files)
**Plan:** filter and 6/41 columns pushed into scan; streaming engine
**Runtime / peak memory:** 38 s / 3.2 GB (pandas version: OOM at 64 GB)
**Checks:** order_id unique; total revenue matches warehouse within 0.01%
**Output:** out/revenue_by_region.parquet (1,404 rows) Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/pandas-to-polars.md
# pandas to Polars migration reference
Read this when converting pandas code. Polars 1.x names are used throughout.
## Mental model shifts
| pandas | Polars |
|---|---|
| Index (row labels), `.loc` by label | No index. Filter with expressions; keep keys as columns |
| Eager, step by step | Lazy plan (`scan_*` / `.lazy()`), optimized, executed on `.collect()` |
| Mutating assignment `df["x"] = ...` | Return new frames: `df.with_columns(...)` |
| `apply` with Python functions | Expressions; `map_elements` only when unavoidable |
| `NaN` doubles as missing | `null` is missing; `NaN` is a float value |
| Object dtype strings | `pl.String` (Arrow); `pl.Categorical` / `pl.Enum` for low cardinality |
| Implicit type coercion | Strict types; explicit `cast` |
## API mapping
| Task | pandas | Polars |
|---|---|---|
| Read CSV | `pd.read_csv(p, dtype=...)` | `pl.scan_csv(p, schema_overrides=...)` |
| Read Parquet | `pd.read_parquet(p, columns=c)` | `pl.scan_parquet(p).select(c)` |
| Select columns | `df[["a", "b"]]` | `df.select("a", "b")` |
| Filter | `df[df.a > 1]` / `df.query("a > 1")` | `df.filter(pl.col("a") > 1)` |
| New column | `df.assign(c=df.a * 2)` | `df.with_columns((pl.col("a") * 2).alias("c"))` |
| Rename | `df.rename(columns={"a": "x"})` | `df.rename({"a": "x"})` |
| Drop | `df.drop(columns=["a"])` | `df.drop("a")` |
| Conditional | `np.where(cond, x, y)` | `pl.when(cond).then(x).otherwise(y)` |
| Multi-branch | `np.select(conds, choices)` | chained `.when().then()` |
| Map values | `s.map(d)` | `pl.col("s").replace(d)` or `replace_strict(d, default=...)` |
| Group agg | `df.groupby("g").agg(n=("x","sum"))` | `df.group_by("g").agg(pl.col("x").sum().alias("n"))` |
| Count rows per group | `.size()` | `pl.len()` |
| Transform | `df.groupby("g")["x"].transform("mean")` | `pl.col("x").mean().over("g")` |
| Cumulative per group | `groupby().cumsum()` | `pl.col("x").cum_sum().over("g")` |
| Shift/lag | `groupby()["x"].shift(1)` | `pl.col("x").shift(1).over("g")` (sort first) |
| Rank | `s.rank(method="dense")` | `pl.col("x").rank("dense")` |
| Merge | `a.merge(b, on="k", how="left", validate="m:1")` | `a.join(b, on="k", how="left", validate="m:1")` |
| Anti join | `a[~a.k.isin(b.k)]` | `a.join(b, on="k", how="anti")` |
| As-of merge | `pd.merge_asof` | `join_asof` |
| Concat rows | `pd.concat([a, b])` | `pl.concat([a, b], how="vertical")` (`"diagonal"` for differing columns) |
| Pivot | `pivot_table(index, columns, values, aggfunc)` | `df.pivot(on=..., index=..., values=..., aggregate_function="sum")` (eager) |
| Melt | `df.melt(id_vars=...)` | `df.unpivot(index=...)` |
| Sort | `sort_values(["a","b"], ascending=[True, False])` | `sort("a", "b", descending=[False, True])` |
| Dedupe | `drop_duplicates(subset=["k"], keep="last")` | `unique(subset=["k"], keep="last", maintain_order=True)` |
| Missing fill | `fillna(0)` | `fill_null(0)` (and `fill_nan` for floats) |
| Drop missing | `dropna(subset=["a"])` | `drop_nulls(subset=["a"])` |
| Is missing | `isna()` | `is_null()` |
| Value counts | `s.value_counts()` | `df["s"].value_counts(sort=True)` |
| Describe | `describe()` | `describe()` |
| String ops | `s.str.lower()`, `s.str.contains(p)` | `pl.col("s").str.to_lowercase()`, `.str.contains(p)` |
| Strip | `s.str.strip()` | `.str.strip_chars()` |
| Datetime parse | `pd.to_datetime(s, format=f)` | `pl.col("s").str.to_datetime(f)` |
| Date part | `s.dt.month` | `pl.col("s").dt.month()` |
| Truncate to month | `s.dt.to_period("M")` | `pl.col("s").dt.truncate("1mo")` |
| Resample | `set_index("ts").resample("D").sum()` | `sort("ts").group_by_dynamic("ts", every="1d").agg(...)` |
| Rolling | `rolling(7).mean()` | `rolling_mean(window_size=7)` |
| Iterate rows | `iterrows()` | Avoid; `iter_rows(named=True)` if truly needed |
| Apply row-wise | `apply(f, axis=1)` | `pl.struct(cols).map_elements(f, return_dtype=...)` (slow) |
| Write Parquet | `to_parquet(p)` | `write_parquet(p)` / `sink_parquet(p)` (lazy) |
## Semantic differences that change results
- `group_by` output order is arbitrary unless `maintain_order=True`.
- pandas `mean()` skips NaN; Polars `mean()` skips null but NaN propagates. Convert NaN
to null with `fill_nan(None)` when migrating data that used NaN for missing.
- pandas `groupby` drops null keys by default; Polars keeps a null group.
- Integer division and overflow: Polars keeps integer dtypes strictly; sum of `Int32`
stays integer, but check for overflow on narrow types.
- `unique()` does not preserve order unless `maintain_order=True`.
- `str.contains` treats the pattern as regex by default in both; use `literal=True` in Polars.
- Polars `join` does not match null keys by default (`nulls_equal=False`), like SQL.
## Migration procedure
1. Freeze pandas outputs for a representative input as Parquet (the oracle).
2. Convert one function at a time; keep signatures DataFrame-in, DataFrame-out.
3. Compare with `polars.testing.assert_frame_equal(new, pl.from_pandas(old), check_row_order=False)`
after sorting both by keys; investigate every mismatch (usually null/NaN or ordering).
4. Switch reads to `scan_*` and move `collect()` to the end once results match.
5. Benchmark wall time and peak memory (`/usr/bin/time -v` on Linux,
`/usr/bin/time -l` on macOS) on production-size input.
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.