Débuter avec Polars

Découvrez Polars, la bibliothèque de DataFrames haute performance pour Python.

Publié le par Gabriel Trouvé (mis à jour le )

78 minutes

Vous connaissez certainement Pandas, sorti pour la première fois en 2008. Il s'agit du framework de référence pour les dataframes conçu pour travailler essentiellement en monothread. Nous avons d'ailleurs un tutoriel sur Pandas.

Polars reprend le même métier mais avec des choix techniques différents : le framework est écrit en Rust et est multithread par défaut.

C'est quoi Polars ?

Si vous connaissez Pandas, Polars fait la même chose : manipuler des données en tableaux : lire un fichier, filtrer des lignes, faire des regroupements et des totaux. Rien de nouveau côté usage, donc. Comme on l'a vu, la grande différence c'est le multithread natif. Le framework a été créé par Ritchie Vink et compte plus de 39 000 étoiles sur GitHub.

Les performances de Polars s'expliquent par des choix techniques que vous pouvez retrouver sur la documentation :

  • Le format colonne d'Apache Arrow : les données d'une même colonne sont stockées côte à côte en mémoire

  • Le multithread par défaut

  • Un optimiseur de requêtes : en mode lazy, Polars lit d'abord tout votre enchaînement d'opérations, détermine la façon la plus efficace de l'exécuter, puis l'exécute

Si vous avez lu nos articles sur uv et ruff, vous reconnaîtrez le schéma : un outil Python historique réécrit en Rust.

Premiers pas avec Polars

C'est le moment de mettre les mains dans le cambouis !

Installation

Polars s'installe comme n'importe quel paquet :

pip install polars
SHELL

Polars demande Python 3.10 minimum.

Nos données de démonstration

Pour avoir un jeu de données de départ, j'ai créé un fichier seed_data.py :

import csv
import random
from datetime import date, timedelta

# La graine garantit que vous obtiendrez exactement les mêmes données que moi
random.seed(117)

commerciaux = [("Patrick", "Lyon"), ("Sebastien", "Paris"), ("Elodie", "Lille")]
prix = {"Clavier": 49.90, "Souris": 25.50, "Ecran": 189.00, "Casque": 79.90}
debut = date(2026, 1, 1)

with open("ventes.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f, lineterminator="\n")
    writer.writerow(["date", "commercial", "ville", "produit", "quantite", "prix_unitaire"])

    for _ in range(500):
        nom, ville = random.choice(commerciaux)
        produit = random.choice(list(prix))
        writer.writerow([
            (debut + timedelta(days=random.randint(0, 180))).isoformat(),
            nom,
            ville,
            produit,
            random.randint(1, 20),
            prix[produit],
        ])
PYTHON

Ce script vous permet de générer un fichier ventes.csv, et la graine vous permet d'avoir les mêmes données que moi.

Lire un fichier

Un DataFrame, c'est un tableau de données, un peu comme une feuille de calcul (pour ceux qui font du Excel) : des lignes, des colonnes, et un nom pour chaque colonne. La particularité, c'est qu'une colonne ne contient qu'une seule sorte de valeurs : que des nombres entiers, que du texte, que des dates. Le vocabulaire est le même que dans Pandas.

import polars as pl

df = pl.read_csv("ventes.csv")
print(df.head())
PYTHON
Cinq premières lignes

Cinq premières lignes

head() renvoie les cinq premières lignes de données par défaut, head(2) en renverrait deux. Pour le nom des colonnes, Polars les affiche systématiquement. Vous remarquerez que le type de chaque colonne est affiché juste sous son nom.

La colonne date contient bien des dates 💡, pourtant Polars la type en chaîne de caractères. Polars devine tout seul les types numériques, mais pas les dates. Il faut alors utiliser try_parse_dates=True.

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)
print(df.head())
PYTHON
La type date est bien affiché

La type date est bien affiché

Attention

try_parse_dates=True va essayer de parser toutes les colonnes qui contiennent du texte. Donc si 2026-02-12 correspond à une référence mais pas à une date, vous risquez d'avoir un problème 😅.

Pour ne viser qu'une seule colonne, vous pouvez imposer le type via schema_overrides :

import polars as pl

df = pl.read_csv("ventes.csv", schema_overrides={"date": pl.Date})
print(df.head())
PYTHON

À savoir que vous pouvez inspecter le schéma de votre dataframe avec l'attribut schema :

import polars as pl

df = pl.read_csv("ventes.csv", schema_overrides={"date": pl.Date})
print(df.schema)

# Résultat : 
# Schema({'date': Date, 'commercial': String, 'ville': String, 'produit': String, 'quantite': Int64, 'prix_unitaire': Float64})
PYTHON

Inspecter les données

Nous allons parler d'un attribut et de deux méthodes, à commencer par shape pour les dimensions :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(df.shape)

# Résultat : (500, 6)
PYTHON

Ensuite, la méthode glimpse(), elle permet d'afficher une colonne par ligne, avec un échantillon de valeurs :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)
df.glimpse()
PYTHON
Rows: 500
Columns: 6
$ date          <date> 2026-02-12, 2026-05-23, 2026-05-15, 2026-04-27, 2026-04-22, 2026-04-09, 2026-02-10, 2026-06-01, 2026-04-10, 2026-01-29
$ commercial     <str> 'Patrick', 'Sebastien', 'Elodie', 'Sebastien', 'Patrick', 'Patrick', 'Sebastien', 'Elodie', 'Sebastien', 'Patrick'
$ ville          <str> 'Lyon', 'Paris', 'Lille', 'Paris', 'Lyon', 'Lyon', 'Paris', 'Lille', 'Paris', 'Lyon'
$ produit        <str> 'Souris', 'Souris', 'Clavier', 'Ecran', 'Ecran', 'Casque', 'Clavier', 'Souris', 'Casque', 'Ecran'
$ quantite       <i64> 7, 14, 19, 2, 3, 14, 14, 5, 8, 19
$ prix_unitaire  <f64> 25.5, 25.5, 49.9, 189.0, 189.0, 79.9, 49.9, 25.5, 79.9, 189.0
SHELL

Pour les statistiques, vous pouvez utiliser describe() :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(df.describe())
PYTHON
Statistiques

Statistiques

Créer un DataFrame à la main

Pour les tests ou un petit jeu de données, vous pouvez créer un DataFrame à la main :

import polars as pl

equipe = pl.DataFrame({
    "commercial": ["Patrick", "Sebastien", "Elodie"],
    "ville": ["Lyon", "Paris", "Lille"],
    "anciennete": [12, 5, 3],
})

print(equipe)
PYTHON
Le dataframe

Le dataframe

Les expressions

Une fonctionnalité primordiale de Polars : les expressions.

Si vous ne souhaitez garder que les commandes supérieures à 19 quantités :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

resultat = df.filter(pl.col("quantite") > 19)
print(resultat)
PYTHON
Ventes supérieurs à 19

Ventes supérieurs à 19

pl.col("quantite") > 19 ne contient aucune donnée. C'est une expression, c'est-à-dire la description d'un calcul à effectuer. Vous pouvez la créer, la stocker dans une variable (grosse_commande = pl.col("quantite") > 19), la réutiliser, sans jamais toucher au moindre DataFrame. Si je m'attarde là-dessus, c'est parce qu'en décrivant vos calculs au lieu de les exécuter immédiatement, Polars peut les analyser, les réorganiser et les répartir sur tous vos cœurs CPU.

Une expression seule ne produit rien : il lui faut un contexte pour être évaluée. Nous allons voir quelques-uns de ces contextes, vous pouvez les retrouver dans la documentation officielle.

select() : choisir et calculer des colonnes

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(df.select("commercial", "produit", "quantite").head(3))
PYTHON
Résultat avec select

Résultat avec select

select() évalue les expressions qu'on lui passe. On peut ainsi ajouter de nouvelles colonnes :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(
    df.select(
        # On garde cette colonne telle quelle
        "commercial",
        # On multiplie deux colonnes, que l'on injecte dans une colonne montant
        (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant"),
    ).head(3)
)
PYTHON
Résultat avec la colonne montant

Résultat avec la colonne montant

À noter

Le résultat ne contient que les colonnes demandées : select() ne conserve rien d'autre.

with_columns() : ajouter des colonnes

On fait à peu près comme avec select(), sauf qu'ici on garde tout. with_columns() conserve les colonnes existantes et ajoute la nouvelle à la suite. Reprenons l'exemple précédent, mais en gardant toutes les colonnes :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

df = df.with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant")
)

print(df.head(3))
PYTHON
Ajout d'une colonne montant

Ajout d'une colonne montant

À noter

with_columns() accepte autant d'expressions que vous le souhaitez, séparées par des virgules. Elles sont évaluées en parallèle.

Les expressions sont indépendantes. Par exemple, si l'on veut utiliser une deuxième expression s'appuyant sur la colonne montant créée par l'expression précédente, on obtiendra une exception polars.exceptions.ColumnNotFoundError: unable to find column "montant"; valid columns: ["date", "commercial", "ville", "produit", "quantite", "prix_unitaire"].

Je ne peux donc pas faire :

df.with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant"),
    (pl.col("montant") * 0.2).alias("tva"),
)
# ColumnNotFoundError
PYTHON

Je dois effectuer deux appels successifs :

df = (
    df.with_columns((pl.col("quantite") * pl.col("prix_unitaire")).alias("montant"))
      .with_columns((pl.col("montant") * 0.2).alias("tva"))
)
PYTHON

filter() : filtrer les lignes

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

# On ne garde que les lignes où plus de 15 unités ont été vendues
print(df.filter(pl.col("quantite") > 15).head(3))

# On combine deux conditions avec & : il faut que les DEUX soient vraies.
# Ici, les ventes d'écrans réalisées par Patrick.
# Chaque condition est entourée de parenthèses, c'est obligatoire.
print(df.filter(
    (pl.col("commercial") == "Patrick") & (pl.col("produit") == "Ecran")
).head(3))
PYTHON
Résultat avec filter

Résultat avec filter

À noter

Comme dans Pandas, on utilise & (ET), | (OU) et ~ (NON).

sort() : trier les données

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

# Les plus grosses ventes en premier
print(df.sort("quantite", descending=True).head(3))

# On peut trier sur plusieurs colonnes, avec un sens différent pour chacune.
# Ici : commercial par ordre alphabétique, puis quantités décroissantes
# à l'intérieur de chaque commercial.
print(df.sort("commercial", "quantite", descending=[False, True]).head(4))
PYTHON
Résultat avec sort.

Résultat avec sort.

Le paramètre descending indique le sens du tri ; il accepte deux formes :

  • un booléen unique appliqué à toutes les colonnes du tri

  • une liste comportant un booléen par colonne, dans le même ordre que celles-ci

Enchaîner les opérations

Tout l'intérêt de Polars réside dans la possibilité de chaîner les méthodes de son API. Chaque méthode renvoie un DataFrame :

import polars as pl

resultat = (
    pl.read_csv("ventes.csv", try_parse_dates=True)
    .with_columns((pl.col("quantite") * pl.col("prix_unitaire")).alias("montant"))
    .filter(pl.col("produit") == "Ecran")
    .sort("montant", descending=True)
    .head(3)
)
PYTHON

Pas besoin de déclarer de variable intermédiaire.

Regrouper, croiser et analyser

Jusqu'ici, nous travaillions sur un seul tableau et traitions les lignes une par une. Nous passons maintenant aux calculs qui résument plusieurs lignes en une seule.

Regrouper et agréger

Combinons les méthodes group_by() et agg() : elles fonctionnent toujours ensemble, mais chacune a un rôle bien précis.

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True).with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant")
)

bilan = (
    df.group_by("commercial")
    .agg(
        pl.col("montant").sum().alias("chiffre_affaires"),
        pl.col("montant").mean().round(2).alias("panier_moyen"),
        pl.len().alias("nb_ventes"),  # pl.len() compte les lignes du groupe
    )
    .sort("chiffre_affaires", descending=True)
)

print(bilan)
PYTHON
Bilan des commerciaux

Bilan des commerciaux

group_by("commercial") rassemble les lignes qui partagent la même valeur : les ventes de Patrick, Sébastien et Élodie. Cette méthode renvoie un objet intermédiaire qui attend qu'on lui précise quoi calculer. C'est agg() qui effectue le calcul. Il s'agit d'un contexte auquel il faut passer des expressions.

Nous en passons trois :

  • La somme des montants sous la colonne chiffre_affaires

  • Le panier moyen

  • Le nombre de ventes

On peut aussi regrouper sur une expression calculée, comme par exemple le mois de la vente :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

par_mois = (
    # On regroupe sur une expression calculée à la volée : le mois de la vente
    df.group_by(pl.col("date").dt.month().alias("mois"))
    # agg() étant un contexte, le montant se calcule directement ici
    .agg((pl.col("quantite") * pl.col("prix_unitaire")).sum().round(2).alias("ca"))
    .sort("mois")
)

print(par_mois)
PYTHON
Regroupement par mois

Regroupement par mois

Vous avez certainement remarqué .dt. Polars organise bien ses méthodes : la méthode .month() n'aurait pas de sens sur du texte, et to_uppercase() n'aurait aucun sens sur une date. Polars range les méthodes logiquement dans différents espaces de noms. En voici quelques-uns :

  • .dt pour les dates et les heures

  • .str pour les chaînes de caractères

  • .list pour les colonnes qui contiennent des listes

  • .name pour agir sur le nom de la colonne plutôt que sur son contenu

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(
    df.select(
        pl.col("commercial").str.to_uppercase().alias("commercial_maj"),
        pl.col("ville").str.len_chars().alias("longueur_ville"),
        pl.col("date").dt.year().alias("annee"),
    ).head(3)
)
PYTHON
Illustration des différents espaces de noms

Illustration des différents espaces de noms

Joindre deux DataFrames

L'idée est de réunir deux tableaux via une colonne commune. Prenons un exemple simple : le chiffre d'affaires réalisé d'un côté, et les objectifs fixés de l'autre. Deux DataFrames séparés, reliés par le nom du commercial.

Nous allons utiliser deux paramètres :

  • on désigne la colonne commune. Elle doit exister dans les deux DataFrames

  • how indique quelles lignes conserver quand une valeur n'a pas de correspondance en face

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True).with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant")
)

# Les objectifs annuels, fixés par la direction
objectifs = pl.DataFrame({
    "commercial": ["Patrick", "Sebastien", "Elodie"],
    "objectif": [150_000.0, 160_000.0, 120_000.0],
})

realise = df.group_by("commercial").agg(pl.col("montant").sum().alias("ca"))

bilan = (
    realise.join(objectifs, on="commercial", how="left")
    .with_columns((pl.col("ca") >= pl.col("objectif")).alias("objectif_atteint"))
    .sort("commercial")
)

print(bilan)
PYTHON
Les deux DataFrames sont liés

Les deux DataFrames sont liés

how="left" garde toutes les lignes du DataFrame de gauche, c'est-à-dire le DataFrame sur lequel la méthode join est appelée. Si un commercial est absent de la table des objectifs, il apparaîtrait quand même avec un null. Par défaut, ce n'est pas left mais inner : dans ce cas, le commercial manquant disparaît du tableau.

Calculer par groupe sans regrouper

Si nous voulons afficher sur chaque ligne la part que représente une vente dans le total du commercial, avec un group_by(), nous perdons le détail des lignes. over() permet de résoudre cela :

import polars as pl

df = pl.read_csv("ventes.csv", try_parse_dates=True).with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant")
)

resultat = df.select(
    "commercial",
    "produit",
    "montant",
    # Le total est calculé par commercial, mais rapporté sur chaque ligne
    (pl.col("montant") / pl.col("montant").sum().over("commercial") * 100)
    .round(2)
    .alias("part_pct"),
)

print(resultat.head(5))
PYTHON
Calculer par groupe avec over

Calculer par groupe avec over

Sélectionner des colonnes par type

Le module polars.selectors permet de cibler des colonnes sans les nommer une par une. Il permet de cibler les colonnes par type :

import polars as pl
import polars.selectors as cs

df = pl.read_csv("ventes.csv", try_parse_dates=True)

print(df.select(cs.numeric()).head(3))   # Toutes les colonnes numériques
print(df.select(cs.string()).head(3))    # Toutes les colonnes texte
PYTHON
Sélectionner les colonnes par type

Sélectionner les colonnes par type

Lazy ou eager : la vraie différence

Tout ce que nous avons écrit jusqu'ici est en mode eager (impatient) : chaque méthode s'exécute immédiatement. C'est ce que vous retrouverez avec Pandas.

Polars propose un second mode : le mode lazy (paresseux). Avec ce mode, Polars va construire un plan d'exécution avant de démarrer le calcul. Il vous appartient ensuite d'exécuter ce plan pour obtenir vos résultats.

.collect() est la méthode la plus courante pour obtenir ce résultat. sink_parquet() déclenche également l'exécution, à la différence près qu'il n'instancie pas de DataFrame en mémoire et écrit directement sur le disque. Un LazyFrame n'effectue aucun calcul tant qu'aucune de ces méthodes n'est appelée.

Pour passer en mode lazy, il suffit de remplacer read_csv par scan_csv :

import polars as pl

print(pl.scan_csv("ventes.csv"))
PYTHON
naive plan: (run LazyFrame.explain(optimized=True) to see the optimized plan)

Csv SCAN [ventes.csv]
PROJECT */6 COLUMNS
ESTIMATED ROWS: 524
SHELL

.collect() est bloquant si vous souhaitez obtenir votre DataFrame :

import polars as pl

resultat = (
    pl.scan_csv("ventes.csv", try_parse_dates=True)   # scan_ au lieu de read_
    .with_columns((pl.col("quantite") * pl.col("prix_unitaire")).alias("montant"))
    .filter(pl.col("produit") == "Ecran")
    .group_by("commercial")
    .agg(pl.col("montant").sum().alias("ca"))
    .collect()  # Ici seulement, ça s'exécute
)

print(resultat)
PYTHON

Le reste du code est identique : votre logique métier ne change pas, seule l'exécution est différente.

La méthode explain() affiche le plan de requête :

import polars as pl

plan = (
    pl.scan_csv("ventes.csv")
    .filter(pl.col("produit") == "Ecran")
    .group_by("commercial")
    .agg(pl.col("quantite").sum())
)

print(plan.explain())
PYTHON
AGGREGATE[maintain_order: false]
  [col("quantite").sum()] BY [col("commercial")]
  FROM
  simple π 2/2 ["commercial", "quantite"]
    Csv SCAN [ventes.csv]
    PROJECT 3/6 COLUMNS
    SELECTION: col("produit") == "Ecran"
    ESTIMATED ROWS: 524
SHELL

PROJECT 3/6 COLUMNS : Polars a compris que seules 3 colonnes sur 6 étaient utiles.

Un exemple avec Streamlit

Si vous avez lu notre article sur Streamlit, vous avez probablement pensé à y intégrer Polars :

import polars as pl
import streamlit as st

st.title("Ventes 2026")

# scan_csv renvoie un LazyFrame : rien n'est encore lu sur le disque
ventes = pl.scan_csv("ventes.csv", try_parse_dates=True).with_columns(
    (pl.col("quantite") * pl.col("prix_unitaire")).alias("montant")
)

commercial = st.selectbox("Commercial", ["Patrick", "Sebastien", "Elodie"])

# Le filtre s'ajoute au plan, puis collect() déclenche l'exécution
resultat = ventes.filter(pl.col("commercial") == commercial).collect()

st.metric("Chiffre d'affaires", f"{resultat['montant'].sum():,.2f} €")
st.dataframe(resultat, hide_index=True)
PYTHON
Intégration avec Streamlit

Intégration avec Streamlit

Nous avons fait un tour assez complet de Polars. Si vous voulez aller plus loin, Polars propose un moteur de streaming, des méthodes pour cohabiter avec Pandas ou encore la possibilité d'écrire du SQL.

Bravo, tu es prêt à passer à la suite

Rechercher sur le site

Inscris-toi à Docstring

Pour commencer ton apprentissage.

Tu as déjà un compte ? Connecte-toi.