Retour au blog
Data & Python

Polars 2.0 est sorti. Son guide de migration répète 12 fois le même avertissement : les résultats de ton pipeline peuvent changer en silence

7 octobre 2026 6 min de lecture

Le mardi 6 octobre, Ritchie Vink a publié « Release of Polars 2.0 » sur le blog de Polars, et la version 2.0.0 est arrivée sur PyPI le même jour. Quand j'ai ouvert le fil Hacker News le mercredi, il affichait 410 points et 95 commentaires.

Le 2 septembre, en annonçant la première release candidate, Vink écrivait que l'équipe espérait que la 2.0 serait « une expérience ennuyeuse pour vous ». La version majeure était là pour changer des valeurs par défaut, pas pour livrer des fonctionnalités.

Puis j'ai lu le guide de migration. Il range 16 changements dans un encadré « Danger », et 12 de ces encadrés portent la même phrase : « This change may silently impact the results of your pipelines » (ce changement peut affecter silencieusement les résultats de vos pipelines).

J'écris des pipelines Python qui récupèrent des enregistrements, les notent et poussent les résultats ailleurs : files de contenu, listes de leads, filtres de pertinence. Ce n'est presque jamais la bibliothèque de dataframes qui les ralentit. Ce sont ses valeurs par défaut qui mordent. Voici donc la 2.0 lue depuis ce siège.

Le changement qui touche chaque requête lazy

Appeler collect() sur un LazyFrame s'exécute désormais sur le moteur streaming au lieu du moteur en mémoire. Les opérations eager sur DataFrame ne changent pas, et les appels sink_* étaient déjà en streaming.

Il fallait une version majeure à cause de l'ordre des lignes. Le moteur streaming ne le garantit pas pour les opérations qui ne l'exigent pas : jointures, group_by, unpivot. Le guide dit que les jointures sont « faciles à rater puisque rien dans la requête ne semble sensible à l'ordre ». Une jointure à gauche qui renvoyait les lignes dans l'ordre de la table de gauche peut ne plus le faire. Si quelque chose en aval prend head(10), écrit un CSV qu'un humain lit de haut en bas, ou compare avec le fichier d'hier, ça vient de changer.

Les correctifs, recopiés du guide :

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 d'environnement POLARS_ENGINE_AFFINITY=in-memory fait la même chose. Le SQL suit la même règle : pl.sql(..., eager=True) s'exécute maintenant sur le moteur streaming.

Les silencieux

La plupart des changements de la 2.0 lèvent une erreur, ce qui est le cas facile. Ceux-ci n'en lèvent pas, et c'est d'abord dans le code de scoring que je les chercherais.

explode() sur une liste vide produit maintenant zéro ligne au lieu d'une ligne nulle. Le nombre de lignes change partout où une colonne de listes contient des listes vides.

Additionner une colonne d'entiers signés à une colonne UInt64 donnait un Float64 avec perte. Ça donne maintenant un Int128 exact. C'est plus correct, mais un dtype différent circule vers ce qui suit.

Combiner un sélecteur avec pl.col(...) via &, | ou ^ ne signifie plus une sélection de colonnes. Pour deux colonnes d'entiers, ça devient silencieusement une opération bit à bit.

pl.datetime(...) nommait sa sortie « datetime ». Il prend maintenant le nom de son argument le plus à gauche, ce qui peut écraser une colonne existante dans un appel à with_columns.

Un schéma passé à read_csv ou scan_csv est maintenant associé par nom de colonne et non par position. Si ton schéma listait les colonnes dans un ordre différent de celui du fichier, la 1.x les étiquetait de travers, comme le montre l'exemple du guide, et la 2.0 corrige ça. Ta sortie change dans les deux cas. Les fichiers sans en-tête obtiennent column_0 au lieu de column_1.

Les valeurs de hachage pour la graine par défaut ont changé, et le guide rappelle que Polars « ne garantit pas la stabilité des hachages entre les versions ». Si tu répartis des utilisateurs en groupes ou tu échantillonnes des lignes avec hash() et que tu as stocké le résultat, les groupes bougent.

Enfin, cut() et qcut() sont dépréciées au profit de bin_intervals(), bin_quantiles() et bin_ranks(). Les deux premières sont fermées à gauche par défaut : passe right_closed=True pour garder les mêmes intervalles (la doc de l'API marque bin_intervals() comme expérimentale). Pour des tranches de score, cet unique argument décide si un 0,5 tombe dans « faible » ou « moyen ».

Les bruyants sont la bonne nouvelle

Le reste de la 2.0, c'est Polars qui devient plus strict, et j'aime ça. Convertir une chaîne en date lève maintenant une erreur ; utilise str.to_date(). is_in() ne force plus de conversion avec perte : l'article de pré-release montre un identifiant utilisateur Int64 supérieur à 2^53 qui correspondait au mauvais identifiant flottant sous la 1.x. Un concat horizontal avec des hauteurs inégales lève maintenant une erreur au lieu de compléter avec des nulls ; how="horizontal_extend" complète volontairement. Les méthodes supprimées lèvent des erreurs typées (AttributeRemovedError, ArgumentRemovedError) qui nomment le remplaçant, ce qui permet aussi à un agent de code de corriger l'appel tout seul.

LazyFrame.profile() a disparu, tout comme le protocole d'interchange DataFrame : pour une bibliothèque comme Seaborn, le correctif du guide est df.to_pandas(). Les requêtes SQL lazy ne sont plus validées à leur construction, seulement au collect() ; appelle collect_schema() si tu comptais sur les erreurs précoces.

L'out-of-core est activé, avec 64 Go par défaut

Le moteur streaming déborde maintenant sur le disque par défaut. L'article de release dit que ça démarre à environ 80 % de la RAM, que ça « peut demander des réglages », et que le budget disque par défaut est de 64 Go. Le tri, les fonctions de fenêtre et beaucoup d'expressions peuvent déborder ; les jointures et les group-bys viendront plus tard.

Sur un petit VPS avec 4 Go de RAM et 40 Go de disque, celui sur lequel je fais tourner mes side projects, ce budget disque par défaut est plus grand que le disque. Dans le code source de la 2.0.0, les valeurs par défaut viennent de POLARS_OOC_MEMORY_BUDGET_FRACTION (0,8) et POLARS_OOC_DISK_BUDGET_MB (64 000), et sous Linux le dossier de débordement est /var/tmp/polars-$USER/spill. Je ne les ai pas trouvées dans le guide utilisateur, donc considère-les comme des réglages internes susceptibles de changer. Dans tous les cas, vérifie l'espace libre de ce dossier avant un gros traitement.

Le benchmark, tel que Polars le rapporte

Polars a exécuté des requêtes dérivées de TPC-H et TPC-DS contre DuckDB 1.5.6, une alpha de DuckDB 2.0 et DataFusion 54.0.0, sur AWS c7a.4xlarge (16 vCPU, 32 Go) et c7a.metal (192 vCPU, 384 Go). Selon son propre résumé, Polars par défaut était le plus rapide sur tous les benchmarks sauf un. Le même article admet un surcoût constant à 192 threads qui pénalise les petites requêtes : au facteur d'échelle 10, passer de 16 à 192 vCPU a rendu Polars par défaut 1,8 fois plus lent sur TPC-DS, et Polars limité à 32 cœurs était compétitif ou gagnant partout.

Sur Hacker News, un développeur de Polars qui poste sous le nom orlp a expliqué pourquoi : la jointure crée T partitions pour chacun des T threads, donc T au carré sur une machine à 192 cœurs. Ma lecture : plus de cœurs ne va pas automatiquement plus vite sur de petites données, et les benchmarks d'éditeur, que Polars dit lui-même non comparables aux résultats TPC publiés, ne remplacent pas un essai sur ta propre machine.

Ce que je ferais lundi

D'abord, épingler. La dernière version de polars sur PyPI est maintenant la 2.0.0, donc une dépendance non épinglée installe la 2.0 au prochain build propre ou à la prochaine exécution de la CI. Écris polars<2 jusqu'à ce que tu aies testé. La dernière version 1.x est la 1.44.2, du 9 septembre.

Ensuite, passer à la 1.44.2 et lancer tes tests avec les avertissements de dépréciation transformés en erreurs. Polars dit que la plupart des fonctionnalités supprimées étaient dépréciées depuis longtemps. Un passage propre ne prouve pas une 2.0 propre, pourtant : le défaut streaming change les résultats, pas les appels.

Troisième étape, le test de référence (golden test). Lance le pipeline sur une entrée fixe sous la 1.44.2, enregistre la sortie en Parquet, puis compare sous la 2.0 (vérifié dans la doc d'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 seule la deuxième échoue, c'est l'ordre : ajoute maintain_order ou un tri explicite. Si la première échoue, vérifie les dtypes et les nombres de lignes avec la liste ci-dessus. Puis fais un grep sur explode(, how="horizontal", pl.datetime(, .hash(, cut(, has_header=False et tout schema= passé à un lecteur CSV.

Ça vaut le coup, ou pas encore

Pour un nouveau projet, je démarrerais directement sur la 2.0 plutôt que de construire sur des valeurs par défaut déjà disparues. Pour les pipelines lazy qui se battent avec la mémoire, la 2.0 est la version à prendre : en septembre, Polars disait s'attendre à ce que le moteur streaming soit « facilement 5 fois plus rapide » en agrégat. Je garderais sur une 1.x épinglée les pipelines dont les consommateurs dépendent de l'ordre des lignes ou de hachages stockés, jusqu'à ce que le golden test passe.

Pandas est une autre question, et la 2.0 ne la tranche pas. Si ton travail est un notebook qui alimente des bibliothèques de graphiques et de stats, tu ne perds rien à rester. Si c'est un job qui tourne sans surveillance à 3 h du matin, je veux ce que Polars a écrit dans son article de release : « Errors should ideally raise up-front, not 20 minutes into a pipeline » (les erreurs devraient idéalement se lever tout de suite, pas 20 minutes après le début d'un pipeline).

La release est ennuyeuse côté fonctionnalités. Côté résultats, elle ne l'est qu'une fois testée.

Sources

Un projet du même genre ?

Je conçois et déploie des produits comme celui-ci. Parlons-en.

Discutons