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.

  • namepolars-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.

~/.claude/skills/polars-dataframes/SKILL.md

--- name: polars-dataframes description: Writes fast, memory-efficient Polars code — LazyFrame scans with predicate and projection pushdown, the expression API, window functions… license: MIT ---

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), not pl.read_*, for anything non-trivial.
  • Express logic with pl.col(...) expressions inside select/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_by unless maintain_order=True or an explicit sort.
  • null (missing) and NaN (float not-a-number) are different; fill_null does 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,
)
NeedJoin
Enrich, keep all left rowshow="left"
Rows with/without a matchhow="semi" / how="anti" (no columns added)
Nearest earlier timestampjoin_asof(other, on="ts", by="symbol", strategy="backward") (both sorted)
Cartesianhow="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 RAMDo
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_csv then .lazy() already loaded everything; scan instead.
  • with_columns expressions run in parallel and cannot see each other’s new columns within the same call; chain another with_columns to use a column just created.
  • pl.col("a").sum().over("g") broadcasts; group_by("g").agg(...) collapses. Pick deliberately.
  • pivot exists only on DataFrame (eager); collect first, or aggregate with group_by + conditional sums for a lazy equivalent. melt was renamed unpivot.
  • String to number casts with strict=True (default) raise on bad values; use cast(pl.Float64, strict=False) and count the resulting nulls.
  • is_in and joins on Categorical columns from different sources can mismatch; prefer pl.Enum with fixed categories or plain String.
  • Row order after joins and group_by is 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 81 lines · 5.2 KB
# 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.

esc