Volver al blog
Datos y Python

Polars 2.0 ya está aquí. Su guía de migración repite 12 veces la misma advertencia: los resultados de tu pipeline pueden cambiar en silencio

7 de octubre de 2026 6 min de lectura

El martes 6 de octubre, Ritchie Vink publicó "Release of Polars 2.0" en el blog de Polars, y la versión 2.0.0 llegó a PyPI ese mismo día. Cuando abrí el hilo de Hacker News el miércoles, tenía 410 puntos y 95 comentarios.

El 2 de septiembre, al anunciar la primera release candidate, Vink escribió que el equipo esperaba que la 2.0 fuera "una experiencia aburrida para ti". La versión mayor estaba ahí para cambiar valores por defecto, no para traer funcionalidades.

Luego leí la guía de migración. Pone 16 cambios en un recuadro "Danger", y 12 de esos recuadros llevan la misma frase: "This change may silently impact the results of your pipelines" (este cambio puede afectar silenciosamente a los resultados de tus pipelines).

Escribo pipelines en Python que recogen registros, los puntúan y envían los resultados a otro sitio: colas de contenido, listas de leads, filtros de relevancia. Casi nunca es la biblioteca de dataframes lo que los frena. Lo que muerde son sus valores por defecto. Así que aquí va la 2.0 leída desde ese asiento.

El cambio que toca cada consulta lazy

Llamar a collect() sobre un LazyFrame ahora se ejecuta en el motor de streaming en lugar del motor en memoria. Las operaciones eager sobre DataFrame no cambian, y las llamadas sink_* ya eran streaming.

Hizo falta una versión mayor por el orden de las filas. El motor de streaming no lo garantiza en las operaciones que no lo requieren: joins, group_by, unpivot. La guía dice que los joins son "fáciles de pasar por alto porque nada en la consulta parece sensible al orden". Un left join que antes devolvía las filas en el orden de la tabla izquierda puede dejar de hacerlo. Si algo más abajo usa head(10), escribe un CSV que una persona lee de arriba abajo, o compara con el archivo de ayer, acaba de cambiar.

Las soluciones, copiadas de la guía:

left.join(right, on="k", how="left", maintain_order="left").collect()

pl.Config.set_engine_affinity("in-memory")  # process-wide
lf.collect(engine="in-memory")  # per query

La variable de entorno POLARS_ENGINE_AFFINITY=in-memory hace lo mismo. El SQL sigue la misma regla: pl.sql(..., eager=True) ahora se ejecuta en el motor de streaming.

Los silenciosos

La mayoría de los cambios de la 2.0 lanzan un error, que es el caso fácil. Estos no, y es en el código de scoring donde los buscaría primero.

explode() sobre una lista vacía ahora produce cero filas en lugar de una fila nula. El número de filas cambia allí donde una columna de listas contiene listas vacías.

Sumar una columna de enteros con signo a una columna UInt64 daba un Float64 con pérdida. Ahora da un Int128 exacto. Es más correcto, pero un dtype distinto fluye hacia lo que viene después.

Combinar un selector con pl.col(...) mediante &, | o ^ ya no significa una selección de columnas. Con dos columnas de enteros, se convierte silenciosamente en una operación bit a bit.

pl.datetime(...) nombraba su salida "datetime". Ahora toma el nombre de su argumento más a la izquierda, lo que puede sobrescribir una columna existente en una llamada a with_columns.

Un esquema pasado a read_csv o scan_csv ahora se asocia por nombre de columna y no por posición. Si tu esquema listaba las columnas en un orden distinto al del archivo, la 1.x las etiquetaba mal, como muestra el ejemplo de la guía, y la 2.0 lo corrige. Tu salida cambia en ambos casos. Los archivos sin cabecera obtienen column_0 en lugar de column_1.

Los valores de hash para la semilla por defecto cambiaron, y la guía recuerda que Polars "no garantiza la estabilidad de los hashes entre versiones". Si repartes usuarios en grupos o muestreas filas con hash() y guardaste el resultado, los grupos se mueven.

Por último, cut() y qcut() quedan obsoletas en favor de bin_intervals(), bin_quantiles() y bin_ranks(). Las dos primeras son cerradas por la izquierda por defecto: pasa right_closed=True para conservar los mismos intervalos (la documentación de la API marca bin_intervals() como experimental). Para franjas de puntuación, ese único argumento decide si un 0,5 cae en "bajo" o en "medio".

Los ruidosos son la buena noticia

El resto de la 2.0 es Polars volviéndose más estricto, y me gusta. Convertir una cadena en fecha ahora lanza un error; usa str.to_date(). is_in() ya no fuerza conversiones con pérdida: la entrada de la pre-release muestra un ID de usuario Int64 superior a 2^53 que coincidía con el ID flotante equivocado en la 1.x. Un concat horizontal con alturas desiguales ahora lanza un error en lugar de rellenar con nulls; how="horizontal_extend" rellena a propósito. Los métodos eliminados lanzan errores tipados (AttributeRemovedError, ArgumentRemovedError) que nombran el reemplazo, lo que también permite a un agente de código corregir la llamada por sí solo.

LazyFrame.profile() ha desaparecido, igual que el protocolo de intercambio de DataFrame: para una biblioteca como Seaborn, la solución de la guía es df.to_pandas(). Las consultas SQL lazy ya no se validan al construirlas, solo en collect(); llama a collect_schema() si contabas con errores tempranos.

El out-of-core está activado, con 64 GB por defecto

El motor de streaming ahora desborda a disco por defecto. La entrada de la release dice que empieza en torno al 80 % de la RAM, que "puede necesitar ajustes", y que el presupuesto de disco por defecto es de 64 GB. La ordenación, las funciones de ventana y muchas expresiones pueden desbordar; los joins y los group-bys llegarán más adelante.

En un VPS pequeño con 4 GB de RAM y 40 GB de disco, el tipo en el que hago correr mis side projects, ese presupuesto de disco por defecto es mayor que el disco. En el código fuente de la 2.0.0, los valores por defecto vienen de POLARS_OOC_MEMORY_BUDGET_FRACTION (0,8) y POLARS_OOC_DISK_BUDGET_MB (64.000), y en Linux el directorio de desbordamiento es /var/tmp/polars-$USER/spill. No los encontré en la guía de usuario, así que trátalos como ajustes internos que pueden cambiar. En cualquier caso, comprueba el espacio libre de ese directorio antes de un trabajo grande.

El benchmark, tal como lo reporta Polars

Polars ejecutó consultas derivadas de TPC-H y TPC-DS contra DuckDB 1.5.6, una alfa de DuckDB 2.0 y DataFusion 54.0.0, en AWS c7a.4xlarge (16 vCPU, 32 GB) y c7a.metal (192 vCPU, 384 GB). Según su propio resumen, Polars por defecto fue el más rápido en todos los benchmarks menos uno. La misma entrada admite una sobrecarga constante a 192 hilos que perjudica a las consultas pequeñas: con factor de escala 10, pasar de 16 a 192 vCPU hizo que Polars por defecto fuera 1,8 veces más lento en TPC-DS, y Polars limitado a 32 núcleos era competitivo o ganaba en todas partes.

En Hacker News, un desarrollador de Polars que publica como orlp explicó por qué: el join crea T particiones por cada uno de los T hilos, es decir, T al cuadrado en una máquina de 192 núcleos. Mi lectura: más núcleos no significa automáticamente más rápido con datos pequeños, y los benchmarks de proveedor, que el propio Polars dice que no son comparables con los resultados TPC publicados, no sustituyen una prueba en tu propia máquina.

Qué haría el lunes

Primero, fijar la versión. La última versión de polars en PyPI es ahora la 2.0.0, así que un requisito sin fijar instala la 2.0 en el próximo build limpio o ejecución de CI. Escribe polars<2 hasta que hayas probado. La última versión 1.x es la 1.44.2, del 9 de septiembre.

Segundo, pasar a la 1.44.2 y ejecutar tus tests con los avisos de obsolescencia convertidos en errores. Polars dice que la mayor parte de lo eliminado estaba obsoleto desde hacía tiempo. Una ejecución limpia no demuestra una 2.0 limpia, sin embargo: el valor por defecto de streaming cambia resultados, no llamadas.

Tercero, el golden test. Ejecuta el pipeline con una entrada fija bajo la 1.44.2, guarda la salida como Parquet y luego compara bajo la 2.0 (comprobado con la documentación de assert_frame_equal):

from polars.testing import assert_frame_equal

assert_frame_equal(old, new, check_row_order=False)  # same rows?
assert_frame_equal(old, new)  # same order?

Si solo falla la segunda, es el orden: añade maintain_order o una ordenación explícita. Si falla la primera, revisa los dtypes y los números de filas con la lista de arriba. Luego haz un grep de explode(, how="horizontal", pl.datetime(, .hash(, cut(, has_header=False y cualquier schema= pasado a un lector CSV.

Merece la pena, o todavía no

Para un proyecto nuevo, empezaría directamente en la 2.0 en lugar de construir sobre valores por defecto que ya no existen. Para los pipelines lazy que pelean con la memoria, la 2.0 es la versión que hay que tomar: en septiembre, Polars dijo que esperaba que el motor de streaming fuera "fácilmente 5 veces más rápido" en conjunto. Dejaría en una 1.x fijada los pipelines cuyos consumidores dependen del orden de las filas o de hashes guardados, hasta que el golden test pase.

Pandas es otra cuestión, y la 2.0 no la resuelve. Si tu trabajo es un notebook que alimenta bibliotecas de gráficos y estadística, no pierdes nada quedándote. Si es un job que corre sin supervisión a las 3 de la madrugada, quiero lo que Polars escribió en su entrada de release: "Errors should ideally raise up-front, not 20 minutes into a pipeline" (los errores deberían saltar idealmente desde el principio, no 20 minutos después de empezar un pipeline).

La release es aburrida en funcionalidades. En resultados, solo lo es una vez probada.

Fuentes

¿Un proyecto del mismo estilo?

Diseño y despliego productos como este. Hablemos.

Hablemos