# Polars 1.x to 2.x upgrade guide November 1, 2026. Companion to [Polars 2 for small and medium businesses](https://jwatte.com/blog/polars-2-small-business-guide/). **Treat this as a report-output migration, even when your script still runs. Polars 2 changes row ordering, empty-list handling, inferred schemas and some numeric results.** Compare your actual business outputs before changing the scheduled job. This kit covers the Python API. The official upgrade overview says it does not yet provide Rust upgrade guides. The target tested here is Polars 2.1.0, released October 25, 2026; the comparison version is 1.44.2. Polars 2.0.0 was released October 6, 2026. Release dates come from [PyPI's public release metadata](https://pypi.org/pypi/polars/json) and the official release notes below. ## Find the environment that actually runs the report Run these in the same virtual environment as the scheduled task. A notebook kernel, a shell and a scheduled job can use different interpreters. ```sh python -I -c "import polars as pl; print(pl.__version__)" uv pip show polars uv pip freeze > requirements-before.txt ``` The two `uv pip` commands assume the environment is active. If you manage an environment with pip instead, use `python -m pip show polars` and `python -m pip freeze`. A virtual environment created by uv need not contain pip itself. | Situation | Decision | Before changing production | |---|---|---| | A critical month-end report is due and nobody can compare outputs | Keep the working pin temporarily | Name an owner and a test date; staying indefinitely is not a maintenance plan | | The script uses removed APIs or depends on incidental row order | Migrate a separate copy | Work through the tables and compare sorted, keyed results | | A new project has no old outputs to preserve | Start with a tested 2.1.0 pin | Define input schemas and expected totals before scheduling | | Native expression plugins, Delta, Iceberg or third-party libraries are involved | Include their maintainers or your developer | Check their supported Polars versions and run integration tests | | You only wanted a faster laptop report | Benchmark your own report first | Record runtime, peak memory, temporary disk and staff review time | ## Upgrade order The [official upgrade overview](https://docs.pola.rs/releases/upgrade/) recommends moving to the latest non-breaking release and addressing its warnings before the breaking release. For this tested transition, use 1.44.2 first. The operational steps below are our release checklist around that recommendation. 1. Preserve the working requirements, script, input files and a complete known-good output. Keep confidential files in approved storage. 2. Create two isolated virtual environments. Install the old and new pins separately. Keep dependency versions recorded, including Excel and database connectors. 3. Run the old script with warnings visible. Resolve deprecations on 1.44.2 where the replacement is available. A warning cleanup alone does not catch silent default changes. 4. Search the tables below for functions you use. Review the silent changes first. Use explicit schemas, aliases, sort keys, null handling and rounding rules where the report depends on them. 5. Run both environments on identical copies of input files, including malformed, empty and boundary cases. Compare output values and schemas. 6. Have the person who uses the report check the differences. Switch the scheduler to the tested new environment only after that review. Keep the old environment and outputs until the next normal reporting cycle passes. ```sh uv venv .venv-old uv venv .venv-new uv pip install --python .venv-old 'polars==1.44.2' uv pip install --python .venv-new 'polars==2.1.0' ``` The commands above create project-relative environments. You can instead use `python -m venv` and install the exact pin with that environment's `python -m pip install`. Activate the intended environment before running a report; on Windows use its Scripts directory, and on macOS or Linux use its bin directory. Install only the connectors your report needs. ## Reading the change tables Every breaking-change heading and every row in the official 2.0 deprecated-functionality table is represented below. Related overloads share a row, as they do in the guide. A few entries appear twice because the guide explains a change and also lists its removed alias. The tables also include the additional breaking topics named by the 2.0 release notes. The code is a small representative probe, not a universal search-and-replace instruction. A replacement can deliberately return a different type or reject bad data. Rows with no equivalent say so. Commands use ripgrep from your project directory; they are review aids, not a parser, and can miss aliases or generated code. Search application code and notebooks too. These table expressions use `import polars as pl` and small synthetic fixtures. Here is the shared fixture setup for running the examples in a disposable working directory. It writes only made-up rows. The Arrow examples need PyArrow; the strategy examples need Hypothesis; Excel fixtures need XlsxWriter and fastexcel. They were tested with those installed wheels, plus pandas and NumPy. `enum.parquet` in its one row means a file carrying the Parquet ENUM logical annotation; our test used a synthetic file with that annotation. Polars does not itself write ENUM annotations. ```python import polars as pl pl.Config.restore_defaults() import polars.testing.parametric as pt from polars.testing import assert_frame_equal,assert_series_equal import pyarrow as pa import numpy as np import io,os,inspect for key in ('POLARS_AUTO_STRUCTIFY','POLARS_STREAMING_CHUNK_SIZE','POLARS_IDEAL_MORSEL_SIZE'): os.environ.pop(key,None) from datetime import datetime,date,timedelta from decimal import Decimal df=pl.DataFrame({"a":[1,2],"b":[3,4]}); lf=df.lazy() csv=b"a,b\n1,3\n2,4" lists=pl.DataFrame({"a":[[1,2],[3,4]]}) structs=pl.Series("s",[{"a":1,"b":2}]) dates=pl.Series([date(2026,1,1)]) timestamps=pl.Series([datetime(2026,1,1),datetime(2026,1,2)]) week_mask=(True,True,True,True,True,False,False) durations=pl.Series([timedelta(days=1),timedelta(days=2),timedelta(days=4)]) cats=pl.Series(["a","b"],dtype=pl.Categorical) e1=pl.Enum(["a","b"]); e2=pl.Enum(["b","c"]) prices=pl.Series([Decimal("1.00"),Decimal("2.50")],dtype=pl.Decimal(10,2)) aware=pl.Series([[datetime(2026,1,1)]]).list.eval(pl.element().dt.replace_time_zone("UTC")) money=pl.DataFrame({"a":[Decimal("0.05")],"b":[Decimal("0.03")]},schema={"a":pl.Decimal(7,2),"b":pl.Decimal(7,2)}) dated=pl.DataFrame({"day":[date(2026,1,1),date(2026,1,2)],"group":["x","x"],"v":[1,2]}) extension_table=pa.table([pa.array([1,2])],schema=pa.schema([pa.field("id",pa.int64(),metadata={b"ARROW:extension:name":b"example:integer"})])) map_table=pa.table({"m":pa.array([[('a',1),('b',2)],None],type=pa.map_(pa.string(),pa.int64()))}) class StreamOnly: def __init__(self,obj): self.obj=obj def __arrow_c_stream__(self,requested_schema=None): return self.obj.__arrow_c_stream__(requested_schema) df.write_csv("data.csv");df.write_ipc("data.arrow");df.write_parquet("data.parquet");df.write_ndjson("data.ndjson");df.write_excel("data.xlsx") expr_json=pl.col("a").meta.serialize(format="json") ``` The datetime-name row replaces `df` with `pl.DataFrame({"year": [2026], "month": [1], "day": [1]})`. The temporal-unit row uses `needle = pl.Series([datetime(2026, 1, 1)], dtype=pl.Datetime("ns"))` and `values = pl.Series([datetime(2026, 1, 1)], dtype=pl.Datetime("us"))`. The seek row first creates `buf = io.BytesIO()` and writes `df.write_parquet(buf)`. Reset the shared `df` before running another row. **Parquet ENUM input prerequisite:** the common fixture block does not create `enum.parquet`. That specific row requires a file from a producer that writes the ENUM logical annotation. We tested it with a separately generated, made-up annotated file. Ordinary string Parquet files do not exercise that change. Do not mistake a normal String column or a Polars Enum for the Parquet ENUM annotation. ## Silent results first These deserve an output comparison even if no exception appears. Some examples show how to preserve the prior output; others retain the corrected behavior and require you to update your expected results. | ID and change | Old code | New code or explicit decision | What changes | Find it | |---|---|---|---|---|---| | U001 Lazy engine and row order | `lf.unpivot(index="a").collect()` | `lf.unpivot(index="a").collect().sort(pl.all())` | auto now chooses streaming for lazy queries, including eager SQL collection. Eager DataFrame operations remain in memory. Sort or request supported maintain_order settings; use engine="in-memory" explicitly when needed. Sinks already streamed. | `rg -n -F -e 'collect' -e 'collect_async' -e 'unpivot' -e 'group_by' -e 'join' .` | | U002 Empty lists in explode | `pl.Series([[1], [], None]).explode()` | `pl.Series([[1], [], None]).explode(empty_as_null=True)` | The new empty_as_null=False drops empty lists; null lists still produce a null row. | `rg -n -F -e 'explode' .` | | U003 Signed plus UInt64 | `pl.Series([1], dtype=pl.Int64) + pl.Series([1], dtype=pl.UInt64)` | `(pl.Series([1], dtype=pl.Int64) + pl.Series([1], dtype=pl.UInt64)).cast(pl.Float64)` | The common dtype becomes Int128 instead of Float64. Prefer exact integers; the explicit float cast shown only preserves an old interface. | `rg -n -F -e 'UInt64' -e 'Int64' .` | | U004 Selector set operators | `df.select(pl.selectors.integer() & pl.col("a"))` | `df.select(pl.selectors.integer() & pl.selectors.by_name("a"))` | A selector combined with pl.col via &, vertical bar, or ^ now performs an elementwise operation, potentially silently. Keep both operands selectors. | `rg -n -F -e 'selectors' -e 'by_name' .` | | U005 datetime and repeat output names | `df.select(pl.datetime("year", "month", "day"))` | `df.select(pl.datetime("year", "month", "day").alias("datetime"))` | Names derive from the leftmost argument rather than datetime/repeat; with_columns can overwrite an existing column. Alias both functions explicitly. | `rg -n -F -e 'pl.datetime' -e 'pl.repeat' .` | | U006 Outer null in list/array to_struct | `pl.Series([None], dtype=pl.List(pl.Int64)).list.to_struct(fields=["x"])` | `pl.Series([None], dtype=pl.List(pl.Int64)).list.to_struct(["x"])` | A null list or array becomes a null struct, rather than a valid struct of null fields. Test row validity; this example accepts the corrected semantics. | `rg -n -F -e 'to_struct' .` | | U007 Zero-width height | `df.drop(pl.all()).shape` | `df.drop(pl.all()).shape` | Dropping every column preserves height. Empty constructors have fixed height zero. Frame transforms retain height; reductions return one row; all zero-width rows compare identical. Use DataFrame(height=n) when adding n rows from scratch. | `rg -n -F -e 'drop' -e 'DataFrame' -e 'LazyFrame' .` | | U008 Arrow stream return type | `pl.from_arrow(StreamOnly(df))` | `pl.from_arrow(StreamOnly(df)).struct.unnest()` | Objects exposing only __arrow_c_stream__ now produce a Series, often Struct. schema/schema_overrides on this path now raise. pyarrow.Table/RecordBatch paths are separate. | `rg -n -F -e 'from_arrow' -e '__arrow_c_stream__' .` | | U009 Unknown Arrow extensions | `pl.from_arrow(extension_table)` | `pl.from_arrow(extension_table).select(pl.all().ext.storage())` | Unknown extension metadata loads as Extension rather than storage dtype. Explicit storage extraction shown; POLARS_UNKNOWN_EXTENSION_TYPE_BEHAVIOR=load_as_storage restores old policy. | `rg -n -F -e 'from_arrow' -e 'Extension' .` | | U010 Map dtype | `pl.from_arrow(map_table)` | `pl.from_arrow(map_table).select(pl.col("m").map.entries())` | Arrow/Parquet/Iceberg maps load as Map with Python dict values, rather than List(Struct). entries restores the old shape. Python dict inference remains Struct. | `rg -n -F -e 'from_arrow' -e 'read_parquet' -e 'scan_parquet' -e 'scan_iceberg' .` | | U011 Struct arithmetic | `pl.select(pl.struct(pl.lit(1).alias("i")) + 1.5)` | `pl.select(pl.struct(pl.lit(1).alias("i")) + 1.5)` | Arithmetic now resolves numeric supertypes per field; non-numeric fields raise. Test decimals/floats and update expected schema. | `rg -n -F -e 'struct' .` | | U012 Decimal precision | `pl.Series([Decimal("9.5")], dtype=pl.Decimal(2,1)).round(0)` | `pl.Series([Decimal("9.5")], dtype=pl.Decimal(2,1)).round(0)` | Rounding that can grow and sign when needed widen precision by one, capped at38. list.sum and grouped/window cum_sum use precision38. Casting back can legitimately fail. | `rg -n -F -e 'round' -e 'ceil' -e 'floor' -e 'sign' -e 'cum_sum' -e 'list.sum' .` | | U013 SQL decimal scales | `money.sql("SELECT a*b AS product, a/b AS ratio FROM self")` | `money.sql("SELECT a*b AS product, CAST(a/b AS DECIMAL(38,2)) AS ratio FROM self")` | SQL multiplication adds scales; division uses max(s1,min(s1+6,12)), half-even rounding, precision38. Expression API unchanged. Choose report scale explicitly. | `rg -n -F -e 'SELECT' -e 'sql' .` | | U014 SQL literals, remainder, division, rounding | `pl.sql("SELECT 1.5 AS literal, -7 % 2 AS remainder, DIV(-7.5,2) AS quotient, ROUND(2.5) AS rounded", eager=True)` | `pl.sql("SELECT 1.5e0 AS literal, -7 % 2 AS remainder, DIV(-7.5,2) AS quotient, ROUND(2.5e0) AS rounded", eager=True)` | Exact non-exponent literals become Decimal. % and DIV truncate toward zero. Decimal DIV returns Decimal(38,0); overflow raises. Decimal ROUND halves go away from zero. Exponent literals retain float behavior, but do not restore floor division. | `rg -n -F -e 'DIV' -e 'ROUND' -e 'SELECT' -e 'MOD' .` | | U015 Temporal membership units | `needle.is_in(values)` | `needle.is_in(values)` | Datetime/Duration membership converts time units exactly; an unrepresentable needle is absent. Previously mismatched units could miss a real match. | `rg -n -F -e 'is_in' -e 'list.contains' .` | | U016 Unknown Enum labels | `pl.Series([["a","z"]]).list.contains(pl.lit("a", pl.Enum(["a","b"])))` | `pl.Series([["a","z"]]).list.contains(pl.lit("a", pl.Enum(["a","b"])))` | Unknown labels now count as absent rather than failing, also for Map Enum keys. Do not rely on membership as category validation. | `rg -n -F -e 'Enum' -e 'is_in' -e 'contains' .` | | U017 Categorical ordering argument | `pl.Categorical("physical")` | `pl.Categorical()` | The positional physical/lexical strings silently become category-pool names. ordering= raises. Ordering is always lexical. | `rg -n -F -e 'Categorical' .` | | U018 Headerless column numbering | `pl.read_csv(b"1,2", has_header=False)` | `pl.read_csv(b"1,2", has_header=False, new_columns=["first","second"])` | Auto names begin at column_0 rather than column_1; applies to headerless Excel/ODS too. Name your columns. | `rg -n -F -e 'has_header' -e 'column_1' .` | | U019 CSV schema names | `pl.scan_csv(b"a,b\nA,B", schema={"b":pl.String,"a":pl.String}).collect()` | `pl.scan_csv(b"a,b\nA,B", schema={"b":pl.String,"a":pl.String}).select("a","b").collect()` | Schema types match header names, rather than positions. Values can change silently. Select explicit output order. extra_columns/missing_columns default None behaves as raise. | `rg -n -F -e 'schema' .` | | U020 Empty headerless CSV | `pl.read_csv(b"", has_header=False, schema={"a":pl.Int64})` | `pl.read_csv(b"", has_header=False, schema={"a":pl.Int64}, raise_if_empty=False)` | raise_if_empty=None resolves to schema is None or has_header; this narrow case now returns an empty typed frame. Set True if an empty file must fail. | `rg -n -F -e 'raise_if_empty' -e 'has_header' .` | | U128 Parquet ENUM logical type | `pl.read_parquet("enum.parquet")` | `pl.read_parquet("enum.parquet").with_columns(pl.col("color").cast(pl.Binary))` | Parquet ENUM annotation now reads as UTF-8 String, formerly Binary. Invalid UTF-8 now raises. Polars itself does not write this annotation. Explicit cast shown only for consumers requiring bytes. | `rg -n -F -e 'read_parquet' -e 'scan_parquet' .` | ## Other breaking changes | ID and change | Old code | New code or explicit decision | What changes | Find it | |---|---|---|---|---|---| | U021 CSV reader dispatch | `pl.read_csv(csv, schema_overrides=[pl.String])` | `pl.read_csv(csv, schema_overrides={"a":pl.String})` | read_csv now uses scan_csv.collect, accepts lists, gains scan options, loses n_threads/batch_size/sample_size/rechunk. A dtype list must cover every column; columns follows requested order. Prefer a name-to-type mapping. | `rg -n -F -e 'read_csv' -e 'n_threads' -e 'batch_size' -e 'sample_size' .` | | U022 IPC reader dispatch | `pl.read_ipc("data.arrow", memory_map=True)` | `pl.read_ipc("data.arrow").rechunk()` | Native read_ipc uses scan_ipc.collect; memory_map and rechunk arguments removed. scan_ipc memory_map was already a no-op. | `rg -n -F -e 'read_ipc' -e 'scan_ipc' -e 'memory_map' .` | | U023 Graph default | `lf.show_graph(raw_output=True, show=False)` | `lf.show_graph(raw_output=True, show=False, plan_stage="ir", engine="in-memory")` | Dedicated guide section says default physical graph; introductory paragraph conflicts about auto engine. Explicit plan_stage and engine avoid relying on that ambiguity. explain remains optimized IR. | `rg -n -F -e 'show_graph' .` | | U024 Profile removal | `lf.group_by("a").agg(pl.col("b").sum()).profile()` | `lf.group_by("a").agg(pl.col("b").sum()).collect()` | No equivalent open source node profiler documented. collect returns results, not timings; Cloud/On-Prem Query Profiler is a separate service. | `rg -n -F -e 'profile' .` | | U025 Horizontal concatenation | `pl.concat([df,pl.DataFrame({"c":[1]})],how="horizontal")` | `pl.concat([df,pl.DataFrame({"c":[1]})],how="horizontal_extend")` | horizontal requires equal heights; horizontal_extend pads. strict=False with horizontal now raises; strict cannot be used with horizontal_extend. Applies to union too. | `rg -n -F -e 'horizontal' -e 'pl.union' .` | | U026 Strict membership coercion | `pl.Series([1]).is_in(pl.Series([1.99]))` | `pl.Series([1]).cast(pl.Float64).is_in(pl.Series([1.99]))` | Lossy implicit is_in coercion raises. Choose a deliberate common representation; blindly casting1.99 to integer would change the question. | `rg -n -F -e 'is_in' .` | | U027 File-like seek | `pl.read_parquet(buf)` | `(buf.seek(0),pl.read_parquet(buf))[1]` | Readers start at the current position; seek after writing before reading. | `rg -n -F -e 'BytesIO' -e 'read_parquet' -e 'read_ipc' -e 'scan_' .` | | U028 Interchange protocol removed | `df.__dataframe__()` | `df.to_arrow()` | __dataframe__, interchange submodules/protocol removed. from_dataframe requires Arrow PyCapsule; allow_copy removed. Convert to Arrow or pandas for consumers. | `rg -n -F -e '__dataframe__' -e 'interchange' -e 'from_dataframe' .` | | U029 Empty transpose | `pl.DataFrame().transpose()` | `pl.DataFrame().transpose()` | Empty transpose now succeeds; zero height and width swap. A zero-width height-n frame yields n Null columns. | `rg -n -F -e 'transpose' .` | | U030 Null shift | `df.shift(None)` | `df.shift(1)` | None now raises rather than warning and making nulls. Choose a real shift amount or construct deliberate null columns. | `rg -n -F -e 'shift' .` | | U031 Deferred SQL resolution | `pl.SQLContext(t=lf).execute("SELECT * FROM missing")` | `pl.SQLContext(t=lf).execute("SELECT * FROM missing").collect_schema()` | Syntax/name errors move to collect or collect_schema; eager=True still checks immediately. New example deliberately raises during validation. | `rg -n -F -e 'execute' -e 'SQLContext' -e 'sql' .` | | U032 Integer/category casts | `pl.Series([0,1],dtype=pl.UInt32).cast(pl.Enum(["a","b"]))` | `pl.Series([0,1],dtype=pl.UInt32).cat.to(pl.Enum(["a","b"]))` | Use cat.to for physical codes to categories and cat.physical for reverse; direct casts now fail. | `rg -n -F -e 'cast' -e 'Categorical' -e 'Enum' .` | | U033 String temporal casts | `pl.Series(["2026-01-01"]).cast(pl.Date)` | `pl.Series(["2026-01-01"]).str.to_date("%Y-%m-%d")` | Use str.to_date/to_datetime/to_time. SQL casts still parse, although invalid-text exception type can change. | `rg -n -F -e 'cast' -e 'pl' -e 'Date' -e 'Datetime' -e 'Time' .` | | U034 Flat to List cast | `pl.Series("a",[1,2]).cast(pl.List(pl.Int64))` | `pl.select(pl.list(pl.Series("a",[1,2])))` | Use explicit list construction; flat-to-list cast removed. | `rg -n -F -e 'cast' -e 'pl.List' .` | | U035 Boolean/integer operators | `pl.select(pl.lit(True) & pl.lit(2))` | `pl.select(pl.lit(True).cast(pl.Int64) & pl.lit(2))` | Mixed boolean and integer operators now fail. Decide whether you want integer bitwise or boolean logic. | `rg -n -F -e 'Boolean' -e 'cast' .` | | U036 Duration statistics | `durations.std()` | `durations.dt.total_microseconds().std()` | std,var,ewm_std,ewm_var no longer operate on Duration. Explicit units produce a numeric statistic, not timedelta. | `rg -n -F -e 'std' -e 'var' -e 'Duration' .` | | U037 Invalid struct cast | `structs.cast(pl.Struct({"a":pl.Int64}))` | `structs.cast(pl.Struct({"a":pl.Int64}),strict=False)` | Mismatched field count/names raises under strict=True; strict=False explicitly permits truncation. | `rg -n -F -e 'cast' -e 'pl.Struct' .` | | U038 Flat arguments where lists required | `lists.select(pl.col("a").list.gather(0))` | `lists.select(pl.col("a").list.gather([0]))` | list.gather,str.contains_any,str.replace_many require list input; implode explicitly for whole-column lists. list.get is per-row indexing. | `rg -n -F -e 'list.gather' -e 'contains_any' -e 'replace_many' .` | | U039 Decimal membership in float data | `prices.is_in([1.005,2.5])` | `prices.is_in([Decimal("1.005"),Decimal("2.5")])` | Decimal needles in float data raise. Integer/Decimal comparisons are exact and no longer cast unrelated data to needle precision. | `rg -n -F -e 'is_in' -e 'contains' .` | | U040 Naive/aware membership | `aware.list.contains(pl.lit(datetime(2026,1,1)))` | `aware.list.contains(pl.lit(datetime(2026,1,1)).dt.replace_time_zone("UTC"))` | Naive versus aware raises; two aware zones compare instants. Choose business timezone explicitly. | `rg -n -F -e 'is_in' -e 'contains' -e 'replace_time_zone' .` | | U041 Non-numeric logarithms | `pl.Series(["1","2"]).log()` | `pl.Series(["1","2"]).cast(pl.Float64).log()` | log/log1p/exp now raise for non-numeric input rather than returning nulls or crashing. | `rg -n -F -e 'log' -e 'log1p' -e 'exp' .` | | U042 list.to_struct shape | `lists.select(pl.col("a").list.to_struct(upper_bound=2))` | `lists.select(pl.col("a").list.to_struct(["x","y"]))` | fields is required; callable fields, upper_bound, n_field_strategy removed. Array width is known so arr.to_struct may omit names, but callable fields removed there too. | `rg -n -F -e 'to_struct' .` | | U043 Business-day keyword arguments | `dates.dt.add_business_days(2, week_mask)` | `dates.dt.add_business_days(2, week_mask=week_mask)` | Arguments after n must use keywords. str.to_decimal inference_length also becomes strictly keyword-only. | `rg -n -F -e 'add_business_days' -e 'to_decimal' .` | | U044 Decimal inference keyword | `pl.Series(["1.5"]).str.to_decimal(100)` | `pl.Series(["1.5"]).str.to_decimal(inference_length=100)` | The old positional argument is a hard error. | `rg -n -F -e 'to_decimal' .` | | U045 Hash seeds and values | `df.hash_rows(10,20,30,40)` | `df.hash_rows(10)` | seed_1/2/3 removed; default hash values change too. Hashes are only stable within a version, so do not use them as permanent business IDs. | `rg -n -F -e 'hash' -e 'seed_' .` | | U046 search_sorted list ambiguity | `pl.Series([1,2,3]).search_sorted([1,2])` | `pl.Series([1,2,3]).search_sorted(pl.Series([1,2]))` | Use Series for many scalar targets or pl.lit(list) for one list-valued target. | `rg -n -F -e 'search_sorted' .` | | U047 Struct field renaming | `structs.struct.rename_fields(["x"])` | `structs.struct.drop(["b"]).struct.rename_fields(["x"])` | Wrong field-name count raises instead of truncating. Drop unwanted fields explicitly first. | `rg -n -F -e 'rename_fields' .` | | U048 CSV file inference limit | `pl.scan_csv([b"a\n1"]*10+[b"a\nA"]).collect()` | `pl.scan_csv([b"a\n1"]*10+[b"a\nA"],schema_overrides={"a":pl.String}).collect()` | Only first10 files inferred by default, formerly all. Set infer_schema_files explicitly or declare schema. | `rg -n -F -e 'scan_csv' -e 'infer_schema_files' .` | | U049 CSV ragged line policy | `pl.read_csv(b"a,b\n1,2,3",truncate_ragged_lines=True)` | `pl.read_csv(b"a,b\n1,2,3",extra_columns="ignore")` | truncate_ragged_lines=None resolves to extra_columns==ignore. Explicit False conflicts with extra_columns=ignore and raises. Review before silently dropping surplus values. | `rg -n -F -e 'truncate_ragged_lines' -e 'extra_columns' .` | ## Removed deprecations Most removals produce `AttributeRemovedError` or `ArgumentRemovedError`, with a replacement hint. Some simply produce built-in AttributeError/TypeError or import errors: interchange/type_aliases/parametric symbols, str.explode, missing_utf8_is_empty_string, corr(ddof), and scan_ipc(memory_map). Old outer join names produce ValueError. Removed environment variables can be silently ignored. Do not assume an exception handler for a single class catches every removal. | ID and change | Old code | New code or explicit decision | What changes | Find it | |---|---|---|---|---|---| | U050 melt | `df.melt(id_vars="a",value_vars="b")` | `df.unpivot(index="a",on="b")` | id_vars/value_vars become index/on. | `rg -n -F -e 'melt' -e 'id_vars' -e 'value_vars' .` | | U051 with_row_count | `df.with_row_count()` | `df.with_row_index("row_nr")` | Default name changes from row_nr to index; explicit name preserves it. | `rg -n -F -e 'with_row_count' .` | | U052 approx_n_unique | `df.approx_n_unique()` | `df.select(pl.all().approx_n_unique())` | Frame method removed; expression remains. | `rg -n -F -e 'approx_n_unique' .` | | U053 fetch | `lf.fetch(1)` | `lf.head(1).collect()` | This limits output, unlike old fetch sampling semantics; test joins/aggregates. | `rg -n -F -e 'fetch' .` | | U054 profile | `lf.group_by("a").agg(pl.col("b").sum()).profile()` | `lf.group_by("a").agg(pl.col("b").sum()).collect()` | No replacement node profiler; collect is results only. | `rg -n -F -e 'group_by' -e 'sum' -e 'profile' .` | | U055 __dataframe__ | `df.__dataframe__()` | `df.to_arrow()` | Interchange protocol removed. | `rg -n -F -e '__dataframe__' .` | | U056 interchange modules | `__import__("polars.interchange.protocol",fromlist=["DtypeKind"])` | `pl.CompatLevel.newest()` | No protocol replacement; CompatLevel alone remains. | `rg -n -F -e 'interchange' -e 'protocol' -e 'fromlist' .` | | U057 Array.width | `pl.Array(pl.Int64,2).width` | `pl.Array(pl.Int64,2).size` | Property renamed. | `rg -n -F -e 'Array' -e 'Int64' -e 'width' .` | | U058 parametric.columns | `pt.columns(["a","b"])` | `[pt.column(n) for n in ["a","b"]]` | Use column objects explicitly. | `rg -n -F -e 'columns' .` | | U059 parametric.create_list_strategy | `pt.create_list_strategy(inner_dtype=pl.Int64)` | `pt.lists(pl.Int64)` | Strategy helper replaced. | `rg -n -F -e 'create_list_strategy' -e 'Int64' -e 'inner_dtype' .` | | U060 dt.datetime | `timestamps.dt.datetime()` | `timestamps.dt.replace_time_zone(None)` | Remove time zone explicitly. | `rg -n -F -e 'dt' -e 'datetime' .` | | U061 dt.with_time_unit | `timestamps.dt.with_time_unit("ms")` | `timestamps.cast(pl.Int64).cast(pl.Datetime("ms"))` | Reinterprets underlying counts; use cast_time_unit if you meant conversion. | `rg -n -F -e 'dt' -e 'with_time_unit' .` | | U062 agg_groups/groups | `df.group_by("a").agg(pl.col("b").agg_groups())` | `df.with_row_index().group_by("a").agg(pl.col("index"))` | Explicit row positions. | `rg -n -F -e 'group_by' -e 'agg_groups' .` | | U063 flatten | `lists.select(pl.col("a").flatten())` | `lists.select(pl.col("a").list.explode(keep_nulls=False,empty_as_null=False))` | Review null/empty semantics. | `rg -n -F -e 'flatten' .` | | U064 Expr.rechunk | `df.select(pl.col("a").rechunk())` | `df.select("a").rechunk()` | Rechunk the collected frame. | `rg -n -F -e 'rechunk' .` | | U065 cat.get_categories | `cats.cat.get_categories()` | `cats.unique()` | unique reflects observed values; Enum dtype.categories includes its full declared domain. | `rg -n -F -e 'cat' -e 'get_categories' .` | | U066 cat locality/ordering | `cats.cat.is_local()` | `cats.dtype` | No local categorical scope; ordering always lexical. to_local and uses_lexical_ordering also removed. | `rg -n -F -e 'cat' -e 'is_local' .` | | U067 Enum.union/or | `e1.union(e2)` | `pl.Enum(pl.concat([e1.categories,e2.categories]).unique(maintain_order=True))` | Explicit category union. | `rg -n -F -e 'union' .` | | U068 auto_structify | `pl.Config.set_auto_structify(True)` | `df.select(pl.struct(pl.all()).alias("values"))` | POLARS_AUTO_STRUCTIFY is silently ignored; explicitly wrap multi-output expressions. | `rg -n -F -e 'Config' -e 'set_auto_structify' .` | | U069 streaming chunk environment | `os.environ.__setitem__("POLARS_STREAMING_CHUNK_SIZE","1000")` | `pl.Config.set_streaming_chunk_size(1000)` | Old environment name silently ignored; new name POLARS_IDEAL_MORSEL_SIZE. Configuration setter still works. | `rg -n -F -e 'streaming' .` | | U070 read_csv_batched | `pl.read_csv_batched("data.csv").next_batches(1)` | `list(pl.scan_csv("data.csv").collect_batches())` | Streaming batch iterator replaces batched reader. | `rg -n -F -e 'read_csv_batched' -e 'csv' -e 'next_batches' .` | | U071 str.explode | `pl.Series(["ab",""]).str.explode()` | `pl.Series(["ab",""]).str.split("").explode()` | Empty string now becomes null rather than preserved string. | `rg -n -F -e 'str' -e 'explode' .` | | U072 missing_utf8_is_empty_string | `pl.read_csv(b"a\n\n",missing_utf8_is_empty_string=True)` | `pl.read_csv(b"a\n\n",empty_string_is_null=False)` | Boolean inverted. | `rg -n -F -e 'read_csv' -e 'missing_utf8_is_empty_string' .` | | U073 corr ddof | `df.select(pl.corr("a","b",ddof=1))` | `df.select(pl.corr("a","b"))` | Parameter had no effect. | `rg -n -F -e 'corr' -e 'ddof' .` | | U074 scan_ipc memory_map | `pl.scan_ipc("data.arrow",memory_map=True).collect()` | `pl.scan_ipc("data.arrow").collect()` | Parameter had no effect on scan. | `rg -n -F -e 'scan_ipc' -e 'arrow' -e 'memory_map' .` | | U075 allow_missing_columns | `pl.scan_parquet("data.parquet",allow_missing_columns=True).collect()` | `pl.scan_parquet("data.parquet",missing_columns="insert").collect()` | False maps to raise. | `rg -n -F -e 'scan_parquet' -e 'parquet' -e 'allow_missing_columns' .` | | U076 reader rechunk | `pl.read_parquet("data.parquet",rechunk=True)` | `pl.read_parquet("data.parquet").rechunk()` | Apply rechunk to result; lazy reads must collect first. | `rg -n -F -e 'read_parquet' -e 'parquet' -e 'rechunk' .` | | U077 map_batches no_optimizations | `lf.map_batches(lambda x:x,no_optimizations=True).collect()` | `lf.map_batches(lambda x:x).collect()` | Pushdown options already default False. | `rg -n -F -e 'map_batches' -e 'no_optimizations' .` | | U078 Arrow future | `df.to_arrow(future=True)` | `df.to_arrow(compat_level=pl.CompatLevel.newest())` | Also write_ipc/write_ipc_stream. | `rg -n -F -e 'to_arrow' -e 'future' .` | | U079 glimpse return_as_string | `df.glimpse(return_as_string=True)` | `df.glimpse(return_type="string")` | Return selector renamed. | `rg -n -F -e 'glimpse' -e 'return_as_string' .` | | U080 top_k/bottom_k descending | `df.top_k(1,by="a",descending=True)` | `df.top_k(1,by="a",reverse=True)` | Direction parameter renamed. | `rg -n -F -e 'top_k' -e 'by' -e 'descending' .` | | U081 rolling/dynamic/upsample by | `dated.group_by_dynamic("day",every="1d",by="group").agg(pl.col("v").sum())` | `dated.group_by_dynamic("day",every="1d",group_by="group").agg(pl.col("v").sum())` | by renamed group_by. | `rg -n -F -e 'group_by_dynamic' -e 'sum' -e 'every' -e 'by' .` | | U082 join_nulls | `df.join(df,on="a",join_nulls=True)` | `df.join(df,on="a",nulls_equal=True)` | Explicit null matching retained. | `rg -n -F -e 'join' -e 'on' -e 'join_nulls' .` | | U083 pivot columns | `df.pivot(values="b",index="a",columns="a")` | `df.pivot(values="b",index="a",on="a")` | columns renamed on. | `rg -n -F -e 'pivot' -e 'values' -e 'index' -e 'columns' .` | | U084 Array width argument | `pl.Array(pl.Int64,width=2)` | `pl.Array(pl.Int64,shape=2)` | Constructor argument renamed. | `rg -n -F -e 'Array' -e 'Int64' -e 'width' .` | | U085 from_dataframe allow_copy | `pl.from_dataframe(df,allow_copy=True)` | `pl.from_dataframe(df)` | Only Arrow PyCapsule now supported. | `rg -n -F -e 'from_dataframe' -e 'allow_copy' .` | | U086 lazy streaming argument | `lf.collect(streaming=True)` | `lf.collect(engine="streaming")` | Also collect_async/explain/show_graph. | `rg -n -F -e 'streaming' .` | | U087 collect_all streaming | `pl.collect_all([lf],streaming=True)` | `pl.collect_all([lf],engine="streaming")` | Also collect_all_async. | `rg -n -F -e 'collect_all' -e 'streaming' .` | | U088 explain tree_format | `lf.explain(tree_format=True)` | `lf.explain(format="tree")` | Display format renamed. | `rg -n -F -e 'explain' -e 'tree_format' .` | | U089 unpivot streamable | `lf.unpivot(index="a",streamable=True).collect()` | `lf.unpivot(index="a").collect()` | Parameter had no effect. | `rg -n -F -e 'unpivot' -e 'index' -e 'streamable' .` | | U090 write/sink retries | `df.write_parquet("retry.parquet",retries=2)` | `df.write_parquet("retry.parquet",storage_options={"max_retries":2})` | Remote IO retry configuration moves to storage_options; local probe checks argument only. | `rg -n -F -e 'write_parquet' -e 'parquet' -e 'retries' .` | | U091 individual optimization flags | `lf.collect(predicate_pushdown=False)` | `lf.collect(optimizations=pl.QueryOptFlags(predicate_pushdown=False))` | All individual flags removed; see complete list below. | `rg -n -F -e 'predicate_pushdown' .` | | U092 with_context | `lf.with_context(pl.LazyFrame({"c":[5,6]})).select(pl.all()).collect()` | `pl.concat([lf,pl.LazyFrame({"c":[5,6]})],how="horizontal").collect()` | Check equal heights and broadcast intent. | `rg -n -F -e 'with_context' .` | | U093 threadpool_size | `pl.threadpool_size()` | `pl.thread_pool_size()` | Function renamed. | `rg -n -F -e 'threadpool_size' .` | | U094 Expr.where | `df.select(pl.col("a").where(pl.col("a")>1))` | `df.select(pl.col("a").filter(pl.col("a")>1))` | Use filter. | `rg -n -F -e 'where' .` | | U095 Expr.shrink_dtype | `df.select(pl.col("a").shrink_dtype())` | `df["a"].shrink_dtype()` | Data-dependent shrink after collection; output is Series here. | `rg -n -F -e 'shrink_dtype' .` | | U096 Expr.register_plugin | `hasattr(pl.Expr,"register_plugin")` | `callable(pl.plugins.register_plugin_function)` | Replace plugin registration API; native third-party plugin execution is outside synthetic tests. | `rg -n -F -e 'Expr' .` | | U097 Expr.from_json | `pl.Expr.from_json(expr_json)` | `pl.Expr.deserialize(io.StringIO(expr_json),format="json")` | Deserializer expects file-like input. Never load untrusted serialized expressions. | `rg -n -F -e 'Expr' -e 'from_json' .` | | U098 Expr.meta.write_json | `pl.col("a").meta.write_json()` | `pl.col("a").meta.serialize(format="json")` | Serialization API renamed. | `rg -n -F -e 'meta' -e 'write_json' .` | | U099 str.concat | `pl.Series(["a","b"]).str.concat()` | `pl.Series(["a","b"]).str.join("-")` | Old default separator hyphen, new default empty; choose explicitly. | `rg -n -F -e 'str' -e 'concat' .` | | U100 has_validity | `pl.Series([1,None]).has_validity()` | `pl.Series([1,None]).has_nulls()` | Use actual null check. | `rg -n -F -e 'has_validity' .` | | U101 Series dt mean/median | `timestamps.dt.mean()` | `timestamps.mean()` | Temporal Series reductions moved to Series itself. | `rg -n -F -e 'dt' -e 'mean' .` | | U102 type_aliases | `__import__("polars.type_aliases")` | `__import__("polars._typing",fromlist=["IntoExpr"])` | Official replacement is internal typing module; prefer own stable application annotations. | `rg -n -F -e 'type_aliases' .` | | U103 outer/outer_coalesce join | `df.join(df,on="a",how="outer")` | `df.join(df,on="a",how="full")` | outer_coalesce becomes full with coalesce=True. | `rg -n -F -e 'join' -e 'on' -e 'how' .` | | U104 to_numpy use_pyarrow | `df.to_numpy(use_pyarrow=False)` | `df.to_numpy()` | Native engine used; explicit to_arrow().to_numpy() for Series Arrow route. | `rg -n -F -e 'to_numpy' -e 'use_pyarrow' .` | | U105 to_numpy zero_copy_only | `df["a"].to_numpy(zero_copy_only=False)` | `df["a"].to_numpy(allow_copy=True)` | Boolean inverted. | `rg -n -F -e 'to_numpy' -e 'zero_copy_only' .` | | U106 replace default/return_dtype | `df["a"].replace({1:10},default=0,return_dtype=pl.Int64)` | `df["a"].replace_strict({1:10},default=0,return_dtype=pl.Int64)` | Use strict replacement when specifying fallback/output type. | `rg -n -F -e 'replace' -e 'Int64' -e 'default' -e 'return_dtype' .` | | U107 map_batches agg_list | `df.group_by("a").agg(pl.col("b").map_batches(lambda s:s,agg_list=True,return_dtype=pl.List(pl.Int64)))` | `df.group_by("a").agg(pl.col("b").implode().map_batches(lambda s:s,return_dtype=pl.List(pl.Int64)))` | Implode explicitly. | `rg -n -F -e 'group_by' -e 'map_batches' -e 'List' -e 'Int64' -e 'agg_list' -e 'return_dtype' .` | | U108 map_elements returns_scalar | `df.select(pl.col("a").map_elements(lambda x:x,return_dtype=pl.Int64,returns_scalar=True))` | `df.select(pl.col("a").map_elements(lambda x:x,return_dtype=pl.Int64))` | Remove ignored parameter. | `rg -n -F -e 'map_elements' -e 'Int64' -e 'return_dtype' -e 'returns_scalar' .` | | U109 json_decode infer_schema_length | `pl.Series(["1"]).str.json_decode(pl.Int64,infer_schema_length=1)` | `pl.Series(["1"]).str.json_decode(pl.Int64)` | Remove parameter; supply dtype. | `rg -n -F -e 'str' -e 'json_decode' -e 'Int64' -e 'infer_schema_length' .` | | U110 rolling min_periods | `df["a"].rolling_mean(2,min_periods=1)` | `df["a"].rolling_mean(2,min_samples=1)` | Also rolling_cov/rolling_corr. | `rg -n -F -e 'rolling_mean' -e 'min_periods' .` | | U111 Series equals strict | `df["a"].equals(df["a"],strict=True)` | `df["a"].equals(df["a"],check_dtypes=True)` | Parameter renamed. | `rg -n -F -e 'equals' -e 'strict' .` | | U112 testing check_dtype | `assert_frame_equal(df,df,check_dtype=True)` | `assert_frame_equal(df,df,check_dtypes=True)` | Also assert_series_equal. | `rg -n -F -e 'check_dtype' .` | | U113 CSV dtypes | `pl.read_csv(csv,dtypes={"a":pl.String})` | `pl.read_csv(csv,schema_overrides={"a":pl.String})` | Parameter renamed. | `rg -n -F -e 'read_csv' -e 'String' -e 'dtypes' .` | | U114 reader row_count arguments | `pl.read_csv(csv,row_count_name="row",row_count_offset=5)` | `pl.read_csv(csv,row_index_name="row",row_index_offset=5)` | Also CSV/IPC/NDJSON/Parquet read/scan variants. | `rg -n -F -e 'read_csv' -e 'row_count_name' -e 'row_count_offset' .` | | U115 scan_ipc cache/file_cache_ttl | `pl.scan_ipc("data.arrow",cache=True,file_cache_ttl=60).collect()` | `pl.scan_ipc("data.arrow").collect()` | File cache no longer supported. | `rg -n -F -e 'scan_ipc' -e 'arrow' -e 'cache' -e 'file_cache_ttl' .` | | U116 NDJSON file_cache_ttl | `pl.scan_ndjson("data.ndjson",file_cache_ttl=60).collect()` | `pl.scan_ndjson("data.ndjson").collect()` | File cache no longer supported. | `rg -n -F -e 'scan_ndjson' -e 'ndjson' -e 'file_cache_ttl' .` | | U117 Excel xlsx2csv_options | `pl.read_excel("data.xlsx",xlsx2csv_options={})` | `pl.read_excel("data.xlsx",engine_options={})` | Also ODS; engine-specific options remain engine-specific. | `rg -n -F -e 'read_excel' -e 'xlsx' -e 'xlsx2csv_options' .` | | U118 Excel read_csv_options | `pl.read_excel("data.xlsx",read_csv_options={})` | `pl.read_excel("data.xlsx",read_options={})` | Also ODS. | `rg -n -F -e 'read_excel' -e 'xlsx' -e 'read_csv_options' .` | | U119 Delta overwrite_schema | `"overwrite_schema" in inspect.signature(pl.DataFrame.write_delta).parameters` | `"delta_write_options" in inspect.signature(pl.DataFrame.write_delta).parameters` | Use delta_write_options={"schema_mode":"overwrite"}; signature-only check, no Delta connector installed or table written. | `rg -n -F -e 'write_delta' .` | | U120 from_repr tbl | `pl.from_repr(tbl=repr(df))` | `pl.from_repr(data=repr(df))` | Parameter renamed. | `rg -n -F -e 'from_repr' -e 'tbl' .` | | U121 SQLContext eager_execution | `pl.SQLContext(t=df,eager_execution=True).execute("SELECT * FROM t")` | `pl.SQLContext(t=df,eager=True).execute("SELECT * FROM t")` | Parameter renamed. | `rg -n -F -e 'SQLContext' -e 'execute' -e 't' -e 'eager_execution' .` | | U122 QueryOptFlags collapse_joins | `pl.QueryOptFlags(collapse_joins=True)` | `pl.QueryOptFlags(predicate_pushdown=True)` | Optimization absorbed into predicate pushdown. | `rg -n -F -e 'QueryOptFlags' -e 'collapse_joins' .` | | U123 parametric size | `pt.series(size=2)` | `pt.series(min_size=2,max_size=2)` | Also dataframes; explicit range. | `rg -n -F -e 'series' -e 'size' .` | | U124 list.to_struct old shape arguments | `lists.select(pl.col("a").list.to_struct(n_field_strategy="max_width",upper_bound=2))` | `lists.select(pl.col("a").list.to_struct(["x","y"]))` | Explicit names determine width. | `rg -n -F -e 'list' -e 'to_struct' -e 'n_field_strategy' -e 'upper_bound' .` | | U131 GroupBy.count | `df.group_by("a").count()` | `df.group_by("a").len(name="count")` | count removed; use len for row counts. Its default output name is len, so name=count explicitly retains the prior column name. Applies to eager and lazy group-by. | `rg -n -F -e 'group_by' -e 'count' .` | ## New deprecations | ID and change | Old code | New code or explicit decision | What changes | Find it | |---|---|---|---|---|---| | U125 Avro unstable | `df.write_avro("data.avro")` | `df.write_avro("data.avro")` | Avro read/write now unstable; enable Config.warn_unstable(True) for notice. No replacement required. | `rg -n -F -e 'read_avro' -e 'write_avro' .` | | U126 Redundant horizontal strict | `pl.concat([df,pl.DataFrame({"c":[1,2]})],how="horizontal",strict=True)` | `pl.concat([df,pl.DataFrame({"c":[1,2]})],how="horizontal")` | Equal heights now mandatory, so explicit strict=True deprecated. | `rg -n -F -e 'horizontal' -e 'strict' .` | | U127 cut/qcut bin methods | `pl.Series([-2,-1,0,1,2]).cut([-1,1],labels=["a","b","c"])` | `pl.Series([-2,-1,0,1,2]).bin_intervals([-1,1],labels=["a","b","c"],right_closed=True)` | qcut maps to bin_quantiles. labels required; default left-closed differs. include_breaks becomes include_intervals with bin/left/right fields. Duplicate quantiles accepted; no allow_duplicates argument. bin_ranks is a separate positional binning option. | `rg -n -F -e 'cut' -e 'qcut' -e 'bin_' .` | ## Release-note additions | ID and change | Old code | New code or explicit decision | What changes | Find it | |---|---|---|---|---|---| | U129 SQL grouped windows and QUALIFY | `df.sql("SELECT a, SUM(b) AS total, SUM(SUM(b)) OVER () AS grand FROM self GROUP BY a ORDER BY a")` | `df.sql("SELECT a, SUM(b) AS total, SUM(SUM(b)) OVER () AS grand FROM self GROUP BY a ORDER BY a")` | 2.0 release notes: windows run over aggregated rows after grouping; QUALIFY evaluates before projection. Recheck grouped/window SQL and filter scope, even when text stays the same. | `rg -n -F -e 'QUALIFY' -e 'OVER' -e 'GROUP' -e 'BY' .` | | U130 Deterministic expression plugins | `callable(pl.plugins.register_plugin_function)` | `callable(pl.plugins.register_plugin_function)` | Release notes permit deterministic plugins to opt into common subexpression/subplan elimination. Native plugins require provider review and their own integration tests; this probe only checks Python registration API availability. | `rg -n -F -e 'register_plugin' -e 'is_elementwise' .` | The removed individual optimization arguments are `no_optimization`, `type_coercion`, `predicate_pushdown`, `projection_pushdown`, `simplify_expression`, `slice_pushdown`, `comm_subplan_elim`, `comm_subexpr_elim`, `cluster_with_columns`, and `collapse_joins`, plus private `_eager`, `_type_check`, and `_check_order`. They occur on lazy collect/explain/show_graph and collect_all. Use the `optimizations` object; do not rely on private flags. The guide has an internal inconsistency about `show_graph()` and the auto engine: its introductory engine paragraph says an explicit streaming engine is needed for a streaming plan, while its dedicated graph section describes auto producing a physical streaming graph. This kit uses explicit `plan_stage` and `engine`. It does not resolve that disagreement by inventing a default. The guide is not a complete changelog: its overview says it focuses on changes not previously deprecated and significant new deprecations, so consult release notes and your integrations too. ## Test results and limits We executed 131 representative rows in isolated Python 3.12 environments with Polars 1.44.2 and 2.1.0, running both columns in both environments. The data was synthetic. All new-column probes completed in 2.1.0 except the intentionally invalid SQL query, which correctly raised at explicit schema validation. A completed call does not establish business equivalence, particularly for the rows that deliberately change types or semantics. Some old-column probes already fail in 1.44.2. The guide's empty CSV, empty transpose, unknown Enum label and invalid SQL cases are expected failures. The Decimal rounding example produced a legacy panic while rendering an out-of-precision result. We also observed an unbound-local error with `to_arrow(future=True)`, and a raised DeprecationWarning with `map_batches(agg_list=True)`. These are recorded observations, not successful old-version recipes. The 2.1.0 replacements completed. Do not downgrade solely to reproduce these failures. Native plugin rows test API availability only; the Delta row checks the signature. Neither establishes third-party binary compatibility or a real table-write migration. The local write retry probe verifies argument acceptance, not a remote retry. Parametric strategy probes construct strategies, rather than validating a complete generated-data property suite. These integrations require your own tests. ### Compare the report, not only the exit code Use one input snapshot for both runs. Include an empty file, an eleventh CSV whose types differ, empty and null lists, a missing join key, duplicate keys, negative amounts, exact-cent values, time zone boundaries and unfamiliar category labels. Keep expected errors in the test set. A script that silently accepts an invalid category may need a separate validation step now. For each output, compare: 1. Column names, data types and units, including Decimal precision and scale. 2. Row counts, distinct business keys and null counts per column. 3. Integer-cent or Decimal sums by location and month, plus a grand total reconciled to the input. 4. Join coverage and duplicate-key counts, including unmatched rows and null keys. 5. The actual rows after sorting by explicit stable business keys. Never compare incidental hash-group or join order. 6. CSV/Excel output column order, date formatting, sheet names and the formulas or downstream imports that use them. Keep numeric tolerances explicit. Exact money should ordinarily compare exactly after an agreed rounding policy. A tolerance for floating-point measurements must come from the business use, rather than being widened until a test passes. Compare serialized file contents through a reader; byte differences alone can reflect metadata or compression. ### Pin and roll back Write `polars==2.1.0` in the approved requirements file and record the complete environment after the successful test. Keep the old requirements with `polars==1.44.2`, its working script and its output snapshot together. Do not overwrite the original inputs during a comparison. If the scheduled report produces a bad total, stop that schedule, restore the old interpreter and script together, rerun from the preserved input and label the replacement output. Check whether downstream imports already consumed the bad file before rerunning anything that sends data elsewhere. A version rollback cannot undo an invoice, email, warehouse load or overwritten source file. If you need a temporary engine comparison, run the same query with `engine="in-memory"` and `engine="streaming"`. This can isolate an engine issue, but it does not undo the other 2.0 API and dtype changes. Monitor temporary disk as well as memory: the 2.0 announcement describes initial spill-to-disk support for sorts, window functions and many expressions, with a roughly 80% available-RAM trigger and a 64 GB default disk budget. It explicitly puts out-of-core joins and group-bys on the roadmap at that release. Do not assume a bigger-than-RAM join is safe because streaming is the default. ## Sources Read November 1, 2026. These are the public sources for the changes above; the examples are our synthetic checks of the named versions. - [Official upgrade overview and order](https://docs.pola.rs/releases/upgrade/), observed 18:00 UTC. - [Official Python 2.0 upgrade guide](https://docs.pola.rs/releases/upgrade/2/), observed 17:55 UTC. - [Polars 2.0.0 release notes](https://github.com/pola-rs/polars/releases/tag/py-2.0.0), observed 17:55 UTC through GitHub's public API. - [Polars 2.1.0 release notes](https://github.com/pola-rs/polars/releases/tag/py-2.1.0), observed 17:55 UTC through GitHub's public API. - [Release of Polars 2.0, October 6, 2026](https://pola.rs/posts/release-polars-2), observed 17:56 UTC. - [PyPI Polars release metadata](https://pypi.org/pypi/polars/json), observed 17:54 UTC. This kit is a software migration checklist. It does not establish that a financial, health or customer-data workflow meets its regulatory obligations.