Skip to content

feat: Add {DataFrame, LazyFrame}.cast - #3815

Open
mkzung wants to merge 2 commits into
narwhals-dev:mainfrom
mkzung:feat/frame-cast
Open

feat: Add {DataFrame, LazyFrame}.cast#3815
mkzung wants to merge 2 commits into
narwhals-dev:mainfrom
mkzung:feat/frame-cast

Conversation

@mkzung

@mkzung mkzung commented Jul 24, 2026

Copy link
Copy Markdown

Description

Adds DataFrame.cast and LazyFrame.cast.

df.cast({"a": nw.Float64, "b": nw.Int32})

Takes a {column: dtype} mapping and casts those columns, leaving the rest unchanged. It uses each backend's native frame cast where there is one (pyarrow Table.cast, pandas/dask astype, polars cast); the SQL backends (duckdb, pyspark) cast per column through with_columns, and ibis inherits that path. Casting a column that is not in the frame raises ColumnNotFoundError, the same as select and drop.

I kept it to the mapping form, which is what was proposed in the issue. Polars also takes a single dtype to cast every column, so I can add that too if you'd like it for parity.

What type of PR is this? (check all applicable)

  • 💾 Refactor
  • ✨ Feature
  • 🐛 Bug Fix
  • 🔧 Optimization
  • 📝 Documentation
  • ✅ Test
  • 🐳 Other

Related issues

AI assistance

  • No AI tools were used for this PR.
  • AI tools were used.

Checklist

  • Code follows style guide (ruff)
  • Tests added
  • Documented the changes

@FBruzzesi FBruzzesi changed the title feat: add DataFrame.cast and LazyFrame.cast feat: Add {DataFrame, LazyFrame}.cast Jul 25, 2026
@FBruzzesi FBruzzesi added the enhancement New feature or request label Jul 25, 2026

@FBruzzesi FBruzzesi left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the contribution @mkzung - I left a few inline comments. On top of those, I wonder about the rationale regarding:

the SQL backends (duckdb, pyspark) cast per column through with_columns, and ibis inherits that path

as listed in the issue, pyspark and ibis have their own casting method and I think we should used them instead of choosing for a user. DuckDB doesn't seem to have it, SQLFrame I didn't check.


Also, there are a few conflicts to solve with the main branch before being able to merge

Comment thread src/narwhals/_arrow/dataframe.py
Comment thread src/narwhals/_pandas_like/dataframe.py Outdated
Comment thread src/narwhals/_polars/dataframe.py Outdated
for name, dtype in dtypes.items()
}
return self._with_native(
self.native.cast(native_dtypes) # pyright: ignore[reportArgumentType]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is # pyright: ignore[reportArgumentType] an upstream polars issue?

mkzung added 2 commits July 28, 2026 15:07
Cast columns to given dtypes from a {name: dtype} mapping, leaving columns not in
the mapping unchanged. Uses the native frame cast on the eager backends (pyarrow
Table.cast, pandas astype, polars cast) and dask astype; the SQL backends (duckdb,
pyspark, ibis) cast per column through with_columns.

Closes narwhals-dev#3402
…e lookup

- ibis: override cast to use the native ir.Table.cast (partial mapping leaves
  other columns untouched) instead of the shared per-column with_columns path
- override _check_columns_exist on LazyFrame to resolve names via
  collect_schema().names(); this avoids the column-access PerformanceWarning that
  self.columns raises on a polars LazyFrame, while the eager DataFrame keeps the
  cheap self.columns path
- pandas-like: read the column dtype from native.dtypes[name] instead of slicing
  the frame with native[name]
- polars: suppress the cast mapping arg-type stub gap with the backend's
  # type: ignore[arg-type] convention
@mkzung
mkzung force-pushed the feat/frame-cast branch from 297bab7 to dcc4110 Compare July 28, 2026 15:23
@mkzung

mkzung commented Jul 28, 2026

Copy link
Copy Markdown
Author

Rebased, conflicts resolved. On the comments:

  • Benchmarked the pyarrow path against with_columns(ns.col(name).cast(...)), same speed. Kept
    the schema version since field.with_type preserves nullability and metadata that the
    per-column cast drops (double not null becomes double).
  • pandas now reads native.dtypes[name] instead of slicing.
  • ibis has a native Table.cast, so it uses that. pyspark/sqlframe/duckdb have no frame-level
    cast, so they stay on the shared path.
  • The polars ignore is just a stubs gap (the mapping key is typed wider than dict[str, ...]).
    Moved it to # type: ignore[arg-type] to match the rest of the polars backend.

Also moved the LazyFrame column check to collect_schema().names(), since self.columns now
warns on lazy polars.

@FBruzzesi

FBruzzesi commented Jul 28, 2026

Copy link
Copy Markdown
Member

Thanks for adjusting @mkzung

  • Benchmarked the pyarrow path against with_columns(ns.col(name).cast(...)), same speed. Kept
    the schema version since field.with_type preserves nullability and metadata that the
    per-column cast drops (double not null becomes double).

Preserving mutability and metadata is a good point. I wonder if we should do it in any cast operation.
Regarding the benchmark, can you please share the script and the numbers with us?

pyspark/sqlframe/duckdb have no frame-level cast, so they stay on the shared path.

What about pyspark.sql.DataFrame.to


Can you also please run all the checks locally as mentioned in the contributing guide?

@mkzung

mkzung commented Jul 28, 2026

Copy link
Copy Markdown
Author

Benchmark script and numbers (pyarrow 23, 500k rows x 8 int columns, 50 runs after warm-up):

import time
import numpy as np
import pyarrow as pa
import narwhals as nw

rng = np.random.default_rng(0)
tbl = pa.table({f"c{i}": rng.integers(0, 100, 500_000) for i in range(8)})
df = nw.from_native(tbl, eager_only=True)
targets = {f"c{i}": nw.Float64 for i in range(8)}

def schema_cast():
    return df.cast(targets)

def with_columns_cast():
    return df.with_columns(*(nw.col(n).cast(d) for n, d in targets.items()))

for fn in (schema_cast, with_columns_cast):
    fn()
    start = time.perf_counter()
    for _ in range(50):
        fn()
    print(f"{fn.__name__}: {(time.perf_counter() - start) / 50 * 1000:.2f} ms")
schema_cast: 1.73 ms
with_columns_cast: 1.83 ms

So no meaningful speed difference; the reason to keep the schema path is only the
nullability/metadata preservation.

On DataFrame.to: it reconciles the whole frame to the given schema, so a partial mapping
would mean rebuilding the full target schema from the current one plus the overrides, and
sqlframe does not implement .to at all (checked both its DuckDB and Spark flavors), so the
spark-like backend cannot rely on it across the board. I can add a pyspark-only path via a
constructed StructType if you think it is worth the special case; otherwise the shared
with_columns path keeps the three SQL backends uniform.

On doing the field-preserving cast everywhere: for the eager per-column Expr.cast there is
no field object to preserve (the expression builds a new array), so this only really applies
where a backend casts a whole table; happy to open an issue to track it if useful.

Checks: prek, pyright/mypy/pyrefly and the full-coverage suite were run locally before the
push; everything on the PR is green now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Enh]: Add support {DataFrame, LazyFrame}.cast(...)

2 participants