Polars behaviors
This document lists the polars behaviors that the checker must work around: a call polars types as valid and then refuses, and a data type whose answer polars decides by a rule no statement captures. Each row gives the input, what polars does, and what the checker does about it.
A gap in the checker’s own implementation is not here: KNOWN_ISSUES.md lists those, and that file exists only while there is one. A weakness the design accepts is not here either: the section “What this gives up” of ARCHITECTURE.md lists those.
The behaviors below were measured against polars 1.43.2.
Calls polars types as valid and then refuses
-
A
datetime.timezoneas a zone.cs.datetime("us", timezone.utc)raisesTypeError: 'timezone' object is not an instance of 'str'. Polars declares the parameter asstr | timezone | Collection[str | timezone | None] | None, so mypy accepts the call on polars’ own signature. The checker reports it as[invalid-call-shape], in the bare form and inside a list. -
A
Listor anArraycompared with a Python value.pl.col("l") == "x"over aList(String)column raisesInvalidOperationError: cannot cast List typeonce the column holds a row.collect_schema()givesBoolean, and a frame with no row givesBooleantoo. The same holds for every comparison member, for aListand anArrayof any element, and for every Python value exceptNone. The checker reports[unsupported-dtype-op]. -
Two integer literals of different standalone types in
coalesce,appendoris_in.pl.coalesce(pl.lit(1), pl.lit(2**40))panics withimplementation error, cannot get ref Int32 from Int64. The panic is aPanicException, which does not inherit fromException.pl.lit(1).append(pl.lit(2**40))raisesSchemaError, andpl.lit(2**40).is_in(pl.lit(1))panics withcannot get ref Int64 from Int32. The cast pass casts each literal to a supertype that is itself an untyped integer, and that cast keeps each literal at the type that it takes alone (polars-core/src/series/mod.rs:392). In the forms ofcoalesceandappend,collect_schema()gives the type of the first literal,Int32. The checker reports the calls ofcoalesceandappendas[invalid-call-shape], and the rules insrc/astrolabe/_rules/refuse all three. -
coalesceof aStructbeside a number.pl.coalesce(pl.lit(1), pl.col("s"))over aStruct({'a': Int8})column panics withcannot get ref Int8 from Struct, andpl.coalesce(pl.col("s"), pl.lit(1))raisesSchemaError. The cast pass casts both toInt8, a cast of a struct to a number leaves a struct, and the kernel reads each later input as the type of the first input with no check (polars-ops/src/series/ops/horizontal.rs:349-365).collect_schema()givesInt8. The rules refuse both. -
extend_constantwith a categorical value.pl.col("x").extend_constant(pl.lit("a", dtype=pl.Enum(["a"])), 1)panics withnot implemented: can not get dtype of Enum AnyValueonce the column holds a row, and aCategoricalvalue does the same. The kernel reads the value as one value and asks for its type (polars-core/src/series/ops/extend.rs:7). A null value, an empty input, and an input of one value equal to the value run.collect_schema()gives the type of the input. The rules refuse it where the input cannot equal the value. -
extend_constantover an input of no row with a value of another type.pl.col("i8").head(0).extend_constant(b"a", 3)over anInt8column keeps the value as it is in a column of the type of the input, with no cast.selectandcollect_schema()giveInt8, and polars panics withunexpected value while building Series of type Int8at the first read of the column, a print, asumor a+ 1, and in the lists ofagg. Its builder of a series takes a value of the kind of the type, an integer that fits, a narrower float, a text for a category, a decimal of no larger scale and a list, and refuses any other value. The rules refuse the call in both contexts, because no read of the column runs. -
sum_horizontalover inputs of the typeNullonly. Where every input has the typeNull, the kernel returns the first input as it is (polars-ops/src/series/ops/horizontal.rs:230-237). Sopl.sum_horizontal(pl.lit(None), pl.col("n"))over a columnnof the typeNullgives one row, and raisesInvalidOperationErrorinagg.collect_schema()givesNull. The overrideNullsGiveFirstInputinsrc/astrolabe/_rules/definitions/overrides.pygives the output the rows of the first input, so the checker reports the call inagg. In aselect, the rows of the frame decide whether the call runs beside other columns (B-2). -
The value
2**63in a chain, cast intoInt64.pl.when(c).then(pl.col("d")).when(e).then(pl.col("u")).otherwise(2**63)over aDatecolumndand aUInt8columnuraisesInvalidOperationError: conversion from u64 to i64 failedonce the frame holds a row. The chain joinsuwith the literal intoUInt64, and then joinsDatewithUInt64intoInt64.collect_schema()givesInt64, and a frame with no row givesInt64too. A negative literal in place ofddoes the same. The checker givesInt64, as it does for aUInt64column that holds under a cast toInt64: the values of a column are not part of its type. -
A
Structin a float function.sqrt,cbrt,exp,log1p,pct_changeandlogend the process with a segmentation fault over aStructcolumn once the column holds a row.logdoes so with any base, and also with aStructcolumn as its base.ewm_meanandewm_sumover aStructcolumn never return: the kernel calls itself with no cast on any input that is not a float (polars-ops/src/series/ops/ewm.rs:42).collect_schema()givesFloat64forexp,log1p,pct_changeandlog, and theStructitself forsqrt,cbrtandewm_mean. A frame with no row givesFloat64. The checker reports[unsupported-dtype-op]. -
index_ofof aUInt128element in aDecimalcolumn.pl.col("d").index_of(pl.lit(1, dtype=pl.UInt128))over aDecimal(10, 2)column panics in the planner withcalled Option::unwrap() on a None value(polars-plan/src/plans/conversion/type_coercion/mod.rs:1720). Every other element that polars cannot cast without loss raises an error. The checker reports[unsupported-dtype-op]for each of them. -
A slice whose offset or length is a column.
pl.col("a").slice(pl.col("o"), 2)raisesComputeError: invalid argument to slice; expected an offset literalon an eager frame. On a lazy frame, the slice pushdown ofcollect()moves the slice below each place of its value, and the offset reads that same place, so the plan holds a cycle. The query never returns where the value is elementwise, aspl.col("a").abs(). Otherwise the slice leaves the tree, and the column comes back whole.collect_schema()gives the type of the value. The checker reports the eager call and the hang as[unsupported-dtype-op], and gives the whole column where the slice leaves the tree. -
A cast of a float to a
Decimalwithstrict=False.pl.Series([1.0]).cast(pl.Decimal(3, 3), strict=False)raisesComputeError: decimal precision 3 can't fit values with 4 digits, where a cast withstrict=Falsegives null for every other value that does not fit. The value 0.5 fits, and the cast runs.collect_schema()givesDecimal(3, 3). The checker givesDecimal(3, 3), because the error depends on the values. -
Arithmetic of a
Structof one field beside aStructof more fields.pl.col("s1") + pl.col("s2")overs1: Struct({'a': Int8})ands2: Struct({'a': Int8, 'b': Int8})raisesDuplicateError: multiple fields with name 'a' foundonce the frame holds a row. Each field of the output takes the name of the one field of the left side (polars-core/src/series/arithmetic/borrowed.rs:454-457).collect_schema()givesStruct({'a': Int8}). The checker reports[unsupported-dtype-op]. -
Arithmetic of a
List(Boolean)beside aList(Null).pl.col("lb") + pl.col("ln")panics withnot implemented for dtype Booleanonce the frame holds a row. The leaves join intoBoolean, and the list kernel has no arithmetic for aBooleanor aNullleaf (polars-core/src/series/arithmetic/list.rs:436).collect_schema()givesList(Boolean). The checker reports[unsupported-dtype-op]. -
A list of one row with one null leaf, beside a column of more rows.
pl.col("l").first() + pl.col("i")over aList(Int64)columnlwhose first value is[null], and anInt64columni, panics withcalled Option::unwrap() on a None value. The broadcast rows share one buffer, and the kernel writes into it in place (polars-core/src/series/arithmetic/list.rs:788). A first value of[1]runs and givesList(Int64), and so does a list of two leaves.pl.litof the same list panics too. The checker givesList(Int64), because the panic depends on the values. -
reverseover anArrayof values that are not numbers.pl.col("a").reverse()over anArray(List(Int64), 2)column panics withreverse for FixedSizeList with non-numeric dtypes not yet supportedonce the column holds two rows. A scalar of one value and a run inaggreorder the rows with no kernel, and run.collect_schema()gives theArrayitself. The rules insrc/astrolabe/_rules/refuse the run. -
A rolling function with weights over nulls.
pl.col("x").rolling_mean(2, weights=[1.0, 1.0])panics withweights not yet supported on array with null valueswhere the column holds a null. The same holds forrolling_min,rolling_max,rolling_sum,rolling_median,rolling_quantile,rolling_varandrolling_std. A count of weights other than the window size panics too.collect_schema()gives the type of the function. The rules refuse a column of only nulls, and a literal text that the cast to a float makes null. -
agg_groupsoutside a group.pl.col("x").agg_groups()in aselect, and over a literal inagg, panics withinternal error: entered unreachable code.collect_schema()givesList(UInt32). The rules refuse it. -
min_byandmax_byinaggover one value for each group.pl.col("x").first().max_by(pl.col("k"))ingroup_by().agg()reads each input at the rows of the groups, with no check of bounds. An input of one value for each group holds fewer rows than the groups index, so the run reads past its end. Over groups of three and two rows ofInt64, polars gives[1, 0]where the first values are[1, 4], and text, binary data, nested and categorical values can end the process. A key ofsum()does the same.collect_schema()gives the type of the value. The checker reports[unsupported-dtype-op]at every type. -
sort_byinaggover a list that a node rebuilt, by one value for each group.pl.col("x").cum_sum().sort_by(pl.col("y").first())ingroup_by().agg()panics withinvalid series dtype: expected List. A function that runs group by group, ascum_sum,shift,rankorrolling_min, builds the list of each group anew, andsort_bythen sorts each list by the list of its key, which a key of one value for each group does not give. A key of one value per row runs.collect_schema()givesList(Int64). The checker reports[unsupported-dtype-op]. -
quantilewith a categorical quantile.pl.col("f").quantile(pl.lit("a", dtype=pl.Categorical))in aselectpanics withnot implemented: can not get dtype of Categorical AnyValue.collect_schema()givesFloat64. The rules refuse it. -
pl.corr(method="spearman")beside a null literal.pl.corr(pl.col("f"), pl.lit(None), method="spearman")panics withvalidity must be equal to the array's length.collect_schema()givesFloat64. The rules refuse it. -
is_empty(ignore_nulls=True)over a null literal inagg.pl.lit(None).is_empty(ignore_nulls=True)ingroup_by().agg()panics with an internal error.collect_schema()givesBoolean. The overrideCountOfNullLiteralinsrc/astrolabe/_rules/definitions/overrides.pyrefuses it. -
ewm_stdandewm_varover aStruct. They end the process with a stack overflow once the column holds a row: the kernel casts the input toFloat64, a struct stays a struct, and the kernel calls itself again.collect_schema()gives theStructitself. The rules refuse it as a crash. -
rolling_corrandrolling_covover text beside one row.pl.rolling_corr(pl.col("s"), pl.lit(1), window_size=2)over aStringcolumn panics withvalidity must be equal to the array's lengthwhere the text does not parse as a number: the cast to a float gives nulls, and the kernel of nulls panics beside an input of one row. Text that parses runs. The rules giveFloat64, because the panic depends on the values.
Answers that the machine decides, not the program
-
mean_horizontalbeside aDecimal. The answer depends on the number of threads polars runs with, so the same source panics on one machine and answers on another.mean_horizontalis the one horizontal aggregate that does not cast its operands to their supertype first, so the raw data types reach the kernel and meet in pairs. Three or more operands meet in a balanced binary tree whose depth rayon takes from the thread count. Where a pair is an integer beside aDecimal, polars casts both toDecimaland then reaches anunreachable!()in its own owned-arithmetic fast path, which ends the process. The reverse pair, aDecimalbeside an integer, takes the borrowed path and answers.With five
Int64operands, oneDecimaland oneFloat64:operands 1 thread 2 threads or more Decimalfourth,Float64fifthpanic Float64Float64third,DecimalfifthFloat64panic A
Decimalbeside aBooleanhas no supertype and raisesSchemaError, in both orders. AFloat64operand rescues the pair only when the tree makes it absorb theDecimalfirst, which again depends on the thread count.sum_horizontaladds in the same order. The checker tries each order of the additions that keeps the operands in their places. Where the orders give one type, that is the answer. Where they differ, the answer isUnchecked.TestThreadsDecideTheOrderinspec/tests/test_polars_facts.pymeasures the first row of the table. -
clipof a literal by a column.pl.lit(1).clip(pl.col("a"))over anInt64column of five rows gives one row when the column holds one chunk. When the column holds two chunks, for example afterpl.concat(frames, rechunk=False)or afterunique(), polars panics withexpected arrays of the same length.collect_schema()givesInt32in both cases. The checker givesInt32, the type of the run that answers. It reads the layout of the chunks as a fact of the data, like the values.TestChunksDecideTheLengthinspec/tests/test_polars_facts.pymeasures this behavior. -
Arithmetic between two dynamic literals. A dynamic literal is
pl.litof an int or a float with nodtype=, or the plain value. Beside a column, polars joins it by the range of its value and not by its own data type. The sum of two such literals takes a different range in different runs.Input, over a frame with the
Booleancolumnb, the group keygand theUInt8columnu:chain = pl.when(pl.col("b")).then(pl.col("u")).otherwise(pl.lit(200) + pl.lit(100))run data type df.select(chain)UInt16df.lazy().select(chain).collect_schema()UInt8df.lazy().select(chain).collect(optimizations=pl.QueryOptFlags.none())UInt8df.group_by("g").agg(chain)List(UInt8)An optimized
selectfolds the constant to 300 and joins by that value. The other runs join by the range of the left literal, 200. In theUInt8runs the value 300 does not fit, and polars wraps it to 44 with no error.The rules follow the passes of each run: the fold of the optimizer where the run folds, and the range of the left literal where it does not. So the checker gives
UInt16in an eagerselect,List(UInt8)inagg, andPlanned[UInt16, UInt8]on a lazy frame. -
A
Booleanconstant beside aListor anArray. Polars refuses|and&between a carrier and aBooleanoperand. But its optimizer folds aBooleanoperand that holds a constant, and then it simplifies the operation.Input, over the
List(Int16)columnl:expression optimized selectunoptimized run and aggpl.col("l") | FalseList(Int16)InvalidOperationErrorpl.col("l") & TrueList(Int16)InvalidOperationErrorpl.col("l").first() | TrueBooleanInvalidOperationErrorpl.col("l").first() & FalseBooleanInvalidOperationErrorTrue | pl.col("l").first()BooleanSchemaErrorAn
Arraycolumn does the same. The constant can also bepl.lit(False)or an expression that the optimizer folds, for examplepl.col("b").first() & False.with_columnsand the streaming engine answer as the optimizedselectdoes.collect_schema()gives the type of the carrier in every form.^, aBooleancolumn, and the other sides of&raise in every run.The rules follow the optimizer of each run, so the checker gives the answer of each row of the table:
List(Int16)forpl.col("l") | Falsein aselect, and an error inagg.pl.col("l") | pl.col("b")is an error, because no run folds a column. -
lenover an operand that polars refuses. An optimized lazyselectthat holds thelenalone drops the operand and counts the rows. Every other run raises.Input, over the
Stringcolumns, theList(String)columnland theInt64columng:expression optimized selectof thelenaloneother runs pl.col("s").abs().len()UInt32InvalidOperationErrorpl.col("l").diff().len()UInt32InvalidOperationErrorpl.col("s").abs().sum().len()InvalidOperationErrorInvalidOperationErrorpl.col("s").abs().len() + 1InvalidOperationErrorInvalidOperationErrorThe other runs are an eager
select, an unoptimized run,agg, and an optimizedselectbeside the columng. The operand of the first two rows keeps one value for each row. The operand of the third row combines the rows.The rules follow the projection pushdown, so the checker gives
UInt32in a lazyselectof thelenalone, and reports the error in every other run.
Declared data types that polars does not keep
The rules give the run type, which collect() gives, beside the planner type, which collect_schema() gives. An eager frame holds the run type. A lazy frame holds both, as Planned[run, planner], and collect() gives the run type. The checker answers below are those of an eager frame.
-
mean_horizontalover oneDecimalcolumn.collect_schema()givesFloat64andcollect()givesDecimal(10, 2). One operand short-circuits before the cast and before the division. The checker gives theDecimal. -
Four forms beside a dynamic literal. Over the
UInt8columnuand theFloat32columnf,collect_schema()andcollect()give different data types:expression collect_schema()collect()pl.concat_list(pl.col("u"), pl.lit(300))List(UInt16)List(Int32)pl.reduce(operator.add, [pl.col("u"), pl.lit(300)])UInt16Int32pl.col("u").shift(1, fill_value=300)UInt8UInt16pl.mean_horizontal(pl.col("f"), pl.lit(1))Float32Float64A chain of a
Datecolumn andpl.lit(1)givesUnknownincollect_schema()andInt32incollect(). The checker gives thecollect()data type in each row. -
shift(fill_value=)with a fill of another kind. Over theInt8columniand theInt16columnj,pl.col("i").shift(1, fill_value=pl.col("j").first())givesInt8incollect_schema()andInt16incollect().collect_schema()gives the type of the receiver at every pair of kinds.collect()gives the supertype of the two kinds where they have one. The checker gives thecollect()data type. -
mean_horizontalover aFloat32beside twoBooleancolumns.collect_schema()givesFloat32andcollect()givesFloat64. TwoBooleancolumns that meet as a pair sum toUInt32, andFloat32besideUInt32widens toFloat64, while the declared type folds the operands one at a time and keepsFloat32. The order counts:pl.mean_horizontal(f32, b)andpl.mean_horizontal(b, f32, b)giveFloat32both ways. The checker givesFloat64for the first shape andFloat32for the other two. -
map_batcheswith both flags insideagg. Over theInt64columna,pl.col("a").map_batches(f, return_dtype=pl.Int64, is_elementwise=True, returns_scalar=True)insidegroup_by("k").agg(...)givesInt64incollect_schema()andList(Int64)incollect(). Withis_elementwise=True, the run ignoresreturns_scalar=Trueand keeps the character of the operand. The checker givesList(Int64). -
A full join with
coalesce=Trueon keys of two integer types. Over aUInt8key on the left and aUInt64key on the right,left.join(right, on="k", how="full", coalesce=True)givesk: UInt8incollect_schema()andk: UInt64incollect()(polars-plan/src/plans/conversion/dsl_to_ir/join.rs:262-317). The other values ofhowgive the same type both ways. The checker givesUInt64. -
A cast of a flat column to a
Listof a logical type.pl.col("i").cast(pl.List(pl.Date))over anInt8column givesList(Date)incollect_schema()andList(Int32)incollect(). The cast wraps each value at the physical type of the target (polars-core/src/chunked_array/cast.rs:75-122). The checker givesList(Int32). -
A slice of one list beside an offset or a length of several rows. Over a
Datecolumndand anInt32columni,pl.col("d").implode().list.slice(pl.col("i"), 1)givesList(Date)incollect_schema()andList(Int32)incollect(). The kernel collects the slices of the one list into a new list, which holds the physical type of the values, andlist.headandlist.tailslice the same way. Inagg, the list of each group then has another type than the plan, and polars raisesComputeError. Over a frame of one row, or a group of one row, the offset has one row too, and the slice keepsList(Date). So the number of rows decides the type, and the checker givesUncheckedin both contexts. -
A cast of a
Structto a type that is not aStructor text.pl.col("s").cast(pl.Int64)over aStruct({'a': Int64})column givesInt64incollect_schema()andStruct({'a': Int64})incollect(). The cast casts each field to the target and keeps the struct (polars-core/src/chunked_array/struct_/mod.rs:303-319). A frame with no row gives the target type,Int64. The checker gives thecollect()data type of a frame with rows. A cast of a column whose data type it does not know gives the target, because the checker takes the kept struct as an oddity of polars.
Values that polars changes without an error
-
A dynamic literal whose value leaves its range. A dynamic literal keeps the range of its first value through a member that changes the value, for example negation. Polars then writes a value that the joined data type cannot hold, and it reports no error.
Over the
UInt8columnuwith the values 1 and 2,-pl.lit(1)keeps the range of 1:expression data type values pl.when(pl.col("b")).then(pl.col("u")).otherwise(-pl.lit(1))UInt81, 255 pl.col("u") + -pl.lit(1)UInt8null, null The chain wraps -1 to 255, and the addition writes null. The checker gives
UInt8, the data type that polars gives. A data type cannot tell a value that fits from one that does not, so the checker reports no error. -
A shift fill that the receiver cannot hold. Where the receiver and the fill of
shift(fill_value=)have no supertype, polars casts the fill to the type of the receiver, and it reports no error. Over theInt8columni, theDatecolumndand theTimecolumnt:expression data type value of the fill pl.col("i").shift(1, fill_value=pl.col("d").first())Int8null for the date 2020-01-01 pl.col("t").shift(1, fill_value=pl.col("d").first())Time00:00:00.000018262 for the same date The cast reads the date as its number of days. The checker gives the data type that polars gives.
-
A strict cast of a flat value to a
List.pl.Series(["a", "1"]).cast(pl.List(pl.Int64), strict=True)gives[[null], [1]]. The strict cast checks the nulls of the outer list only, so a value that does not parse becomes an inner null with no error. The checker givesList(Int64), the data type that polars gives. -
An integer cast to categories inside a list or an array. A cast of a list to an array, or of an array to a list, runs through the arrow kernel. That kernel casts an integer to the physical id of a category, and it does not check that the process holds the id. In a new process,
pl.Series([[5]], dtype=pl.Array(pl.Int64, 1)).cast(pl.List(pl.Categorical))givesList(Categorical)with no error. A later read of the values, for exampleto_list(), then crashes the process. AnEnumreads an index out of its range as the empty text. The checker gives the data type that polars gives. -
rolling_corrandrolling_covbeside one row. Where one input holds a null and the other input has one row, which polars spreads, polars panics withvalidity must be equal to the array's length:pl.rolling_corr(pl.col("a"), pl.col("x").first(), window_size=2)over a columnathat holds a null. The same call runs whereaholds no null. The nulls of the data decide the panic, so the checker givesFloat64, the type of the run that does not panic. Where the code decides a null, as in the first row ofshift(1)or ofrolling_mean(2), the checker reports the panic. -
sortover one value for each group inagg.df.group_by("g").agg(pl.col("l").first().sort())over aList(Int64)column panics withcapacity overflow, and the process can then allocate memory with no bound. The sort reads the flat values of the input with the indices of the rows of each group, with no check of the bounds (polars-expr/src/expressions/sort.rs:70-85), and an aggregation holds one value for each group only. So the read is out of bounds. A list, an array of lists and a struct with a text field crash. A text and aCategoricalcrash in some processes, depending on the memory of the process. A number reads values that are not those of the column. The rules give the type of the input, andspec/tests/test_definitions_node_kinds.pyleaves these cells out. -
A
rollingexpression over an index of one value, in theaggofrolling.pl.col("x").sum().rolling(index_column=pl.col("x").first(), period="2i")panics withindex out of boundsin theaggofgroup_by, and withrange end index 2 out of range for slice of length 1in theaggofgroup_by_dynamic. In theaggofrolling, the same call runs, and its windows read the rows of other windows: overx = [10, 20, 30, 40, 50]and the windows ofrolling("i", period="2i"), the third window holds the values20and30, and gives[30, 30], the sum of10and20. In that context, polars also acceptsperiod="2d"over the integer index, whichselectrefuses.collect_schema()givesList(Int64). The rules refuse the call in eachagg, as polars does ingroup_by.