Abbiamo passato dieci lezioni dentro pandas, che è il modo in cui la maggior parte del lavoro Python su dati in produzione nel 2026 succede ancora: il fossato dell’ecosistema è enorme, scikit-learn vuole un DataFrame, ogni notebook su Kaggle parte con import pandas as pd. Ma pandas non è più l’unica libreria DataFrame, e per certi tipi di lavoro non è più la migliore. Oggi incontriamo Polars, la libreria DataFrame di seconda generazione, e impariamo abbastanza per leggerne il codice, scrivere una pipeline al suo interno, e giudicare quando allungare la mano per prenderlo.
Da dove viene Polars
Polars è stato avviato da Ritchie Vink, un ingegnere olandese, nel 2020. Era frustrato dalla performance di pandas sul tipo di dati medio-grandi (10-100 GB) che è comune nell’industria ma scomodo per pandas: troppo grande per essere comodo, troppo piccolo per giustificare Spark. Ha scritto il core in Rust, con Apache Arrow come formato in-memory, e un binding Python sopra. La prima release 1.0 è stata nel 2024; per il 2026 Polars è alla versione 1.x, maturo, ed è la libreria DataFrame di default per una fetta significativa del nuovo lavoro Python su dati.
Le quattro decisioni di design che rendono Polars diverso da pandas:
- Core Rust, parallelo per default. Ogni operazione che può usare più core lo fa, senza configurazione. Su un portatile a 16 core, un groupby di Polars è grosso modo 8-15 volte più veloce dello stesso groupby pandas, e la maggior parte di quello viene dai core.
- Formato di memoria Arrow ovunque. Nessun fallback NumPy; tutto è Arrow colonnare. Stringhe, date, valori mancanti: tutti nativi, tutti efficienti.
- Un’API lazy con un query optimizer. Questo è il pezzo che distingue Polars dal “pandas veloce”. Più su questo tra un attimo.
- Nessun index. I frame Polars sono solo rettangoli di colonne. Non c’è
.set_index, niente label di riga, nessuna gerarchia multi-index. Tutto quello che pandas fa con l’index, Polars lo fa con colonne esplicite. Dopo il primo giorno di astinenza, è un sollievo.
Le due API: eager e lazy
Polars ha due modi di esprimere lo stesso calcolo, e la differenza tra loro è la cosa più importante da capire della libreria.
La modalità eager è quello che fa pandas: ogni operazione gira immediatamente e restituisce un nuovo DataFrame. È naturale per l’esplorazione in un notebook:
import polars as pl
df = pl.read_csv("sales.csv")
filtered = df.filter(pl.col("amount") > 100)
grouped = filtered.group_by("country").agg(pl.col("amount").sum())
Ogni riga è girata. Ogni riga ha allocato. L’ordine di esecuzione è esattamente quello che hai scritto.
La modalità lazy è quella che conta. Invece di eseguire ogni passo, costruisci un piano di query, lo passi a Polars, e lasci che il planner ottimizzi prima di eseguire:
result = (
pl.scan_csv("sales.csv") # scan, non read; nessun I/O ancora
.filter(pl.col("amount") > 100)
.group_by("country")
.agg(pl.col("amount").sum())
.collect() # ORA gira
)
Nota scan_csv invece di read_csv e .collect() alla fine. Fino a collect(), niente viene eseguito: Polars ha solo costruito un piano. Poi il planner guarda l’intero piano e lo riscrive: spinge il filtro giù dentro il reader CSV (così le righe con amount <= 100 non vengono mai parsate in primo luogo), capisce quali colonne sono effettivamente usate e legge solo quelle, sceglie la migliore strategia parallela, e poi esegue.
Questo è lo stesso trucco che usano Spark e la maggior parte dei motori SQL moderni. Il salto mentale per chi viene da pandas è reale: in pandas, ottimizzi calibrando a mano l’ordine delle operazioni. In modalità lazy di Polars, scrivi le operazioni nell’ordine più chiaro, e il planner le riordina.
La regola del pollice: il codice di produzione dovrebbe usare lazy. I notebook e l’esplorazione usano eager. La transizione da uno all’altro è piccola (cambia read_* in scan_* e aggiungi .collect() alla fine) ma lo speedup su dati reali è spesso 5-10 volte sopra al fatto che Polars è già veloce.
L’expression API
L’altro grande cambiamento è come si fa riferimento alle colonne. In pandas, df["x"] ti dà una Series; puoi passarla in giro, farci matematica, e riassegnarla. In Polars, usi pl.col("x") dentro le expression:
df.with_columns(
(pl.col("revenue") * 1.22).alias("revenue_with_vat"),
pl.col("country").str.to_uppercase().alias("country_upper"),
)
pl.col("revenue") non è un valore di colonna: è un’expression che dice “la colonna chiamata revenue, in qualunque frame questo venga applicato”. È una descrizione, non un fetch. È quello che permette al planner di ragionare sulla query.
Questo è anche il motivo per cui df.with_columns(...) invece del df["new"] = ... di pandas. I frame Polars sono immutabili; ogni operazione restituisce un nuovo frame. Non muti, derivi.
Qualche pattern di expression comuni:
# Filter
df.filter(pl.col("age") > 30)
df.filter((pl.col("age") > 30) & (pl.col("country") == "IT"))
# Aggiungere colonne
df.with_columns([
(pl.col("a") + pl.col("b")).alias("sum"),
pl.col("name").str.to_lowercase().alias("name_lower"),
])
# Group e aggregate
df.group_by("country").agg([
pl.col("revenue").sum().alias("total"),
pl.col("revenue").mean().alias("avg"),
pl.col("customer_id").n_unique().alias("customers"),
])
# Sort
df.sort("revenue", descending=True)
# Join (nessun index, quindi sempre on= esplicito)
left.join(right, on="customer_id", how="inner")
Nota che ogni aggregato è nominato esplicitamente con .alias(...). Polars non auto-nomina; dici quello che vuoi.
Un cheat sheet pandas-Polars
I mapping più spesso necessari, fianco a fianco:
| Compito | pandas | Polars |
|---|---|---|
| Leggere CSV | pd.read_csv("f.csv") | pl.read_csv("f.csv") / pl.scan_csv(...) |
| Leggere Parquet | pd.read_parquet(...) | pl.read_parquet(...) / pl.scan_parquet(...) |
| Selezionare colonne | df[["a", "b"]] | df.select(["a", "b"]) |
| Filtrare righe | df[df["a"] > 5] | df.filter(pl.col("a") > 5) |
| Aggiungere colonna | df["c"] = df["a"] + df["b"] | df.with_columns((pl.col("a") + pl.col("b")).alias("c")) |
| Eliminare colonna | df.drop(columns=["a"]) | df.drop("a") |
| Group + sum | df.groupby("k")["x"].sum() | df.group_by("k").agg(pl.col("x").sum()) |
| Sort | df.sort_values("x") | df.sort("x") |
| Join | df.merge(other, on="k") | df.join(other, on="k") |
| Rinominare | df.rename(columns={"a": "b"}) | df.rename({"a": "b"}) |
| Parte di data | df["d"].dt.year | df.with_columns(pl.col("d").dt.year()) |
| Riempire null | df["x"].fillna(0) | df.with_columns(pl.col("x").fill_null(0)) |
| Apply (da evitare) | df["x"].apply(f) | df.with_columns(pl.col("x").map_elements(f)) |
| A pandas | - | df.to_pandas() |
| Da pandas | - | pl.from_pandas(pdf) |
Una nota su apply: in Polars è map_elements, e la libreria ti darà un warning a runtime se lo usi perché il Python per riga è ancora più ovviamente la risposta sbagliata qui che in pandas; le expression Polars coprono quasi tutto quello che vorresti.
Streaming di dati piu’ grandi della memoria
Questo è il secondo grande trucco di Polars. collect(streaming=True) esegue il piano lazy in modalità streaming, processando i dati a chunk sotto il cofano senza mai materializzare l’intero frame:
result = (
pl.scan_parquet("data/year=2025/*.parquet") # 80 GB attraverso i file
.filter(pl.col("country") == "IT")
.group_by("month")
.agg(pl.col("revenue").sum())
.collect(streaming=True)
)
Quella è la forma di una query che farebbe assolutamente fondere pandas (80 GB su un portatile), e Polars la esegue in qualche minuto con memoria limitata. Non ogni operazione supporta lo streaming ancora (join su frame enormi, alcune window function), ma il caso comune (filter, group, aggregate) sì, e la copertura si è allargata a ogni release.
Lavorare con entrambe le librerie
La conversione tra Polars e pandas è economica, perché entrambe parlano Arrow:
# Frame Polars a pandas
pdf = df.to_pandas(use_pyarrow_extension_array=True)
# Frame pandas a Polars
df = pl.from_pandas(pdf)
Con use_pyarrow_extension_array=True, la conversione è zero-copy: stessa memoria, vista diversa. Quindi il pattern pratico nel 2026 è: usa Polars per il lavoro pesante sui dati (load, filter, aggregate, transform), converti a pandas solo quando devi alimentare sklearn o una libreria di plotting che non parla ancora Polars:
features = (
pl.scan_parquet("training_data.parquet")
.filter(pl.col("year") >= 2024)
.group_by("user_id")
.agg([
pl.col("revenue").sum().alias("total_rev"),
pl.col("session_count").mean().alias("avg_sessions"),
])
.collect()
)
# Passa a scikit-learn
X = features.to_pandas()
model.fit(X.drop(columns=["user_id"]), y)
Questa è la strada pragmatica: Polars per il data engineering, pandas per il passaggio al modello.
Quando vince Polars, quando vince pandas
Polars vince quando:
- I dati sono medio-grandi (1 GB e su). Il core Rust e il parallelismo dominano.
- Il codice è una pipeline: load, filter, group, aggregate, write, dove il planner lazy può fare lavoro reale.
- Stai partendo da zero e non devi integrare con una libreria solo-pandas.
- I dati non entrano in memoria, e puoi usare lo streaming.
Pandas vince ancora quando:
- Hai bisogno di scikit-learn / statsmodels / qualcosa di vecchio che vuole un DataFrame pandas come input.
- Stai facendo esplorazione approssimativa in un notebook su dati piccoli; l’API pandas è più indulgente per domande one-off.
- La codebase è già pandas e lo speedup non giustificherebbe la riscrittura. Codebase miste si fanno confuse.
- Una libreria da cui dipendi (un connector di nicchia, un SDK di dominio) restituisce un frame pandas e non vuoi aggiungere l’attrito di conversione ovunque.
In pratica la mia raccomandazione nel 2026 è: pipeline nuove, Polars; codebase pandas esistenti, lasciale stare a meno che non stiano colpendo un muro di performance; esplorazione in notebook, qualunque tu pensi più in fretta, e sempre più spesso quello è anche Polars, una volta che la memoria muscolare scatta.
Qualche cosa che morde gli utenti pandas
Tre punti di attrito da segnalare, perché sono dove una persona fluente in pandas passa il suo primo pomeriggio di confusione Polars:
I riferimenti a colonna non sono stringhe, sono expression. In pandas puoi fare df.groupby("country")["revenue"].sum() perché i nomi di colonna sono stringhe che indicizzano un frame. In Polars, la stessa operazione è df.group_by("country").agg(pl.col("revenue").sum()): pl.col("revenue") è l’unità su cui operi, non la stringa. Una volta che questo scatta, ogni firma di metodo Polars smette di sembrare strana.
Niente assegnazione concatenata. Il df["new_col"] = ... di pandas muta il frame. I frame Polars sono immutabili; devi dire df = df.with_columns(...) e riassegnare. Questo è fastidioso per un minuto e un sollievo per sempre dopo, perché l’intera classe di bug “ho assegnato a una copia per sbaglio” sparisce.
I join sono espliciti su on=. Pandas usa l’index per i join per default se non passi on=. Polars non ha index, quindi passi sempre on= (o left_on= / right_on=). Un po’ più di battitura, molto meno tirare a indovinare.
La gestione dei null è coerente. Ogni tipo è nullable, i valori mancanti sono null (non NaN, non NaT, non None a seconda del dtype), e pl.col("x").fill_null(0) funziona sempre allo stesso modo. Dopo anni del casino NaN-vs-None-vs-NaT di pandas, questa è una piccola gioia.
Un piccolo esempio di lavoro
La forma completa di uno script Polars (read, transform, aggregate, write) per darti il ritmo:
import polars as pl
result = (
pl.scan_parquet("orders/*.parquet")
.filter(pl.col("status") == "completed")
.with_columns([
(pl.col("quantity") * pl.col("unit_price")).alias("revenue"),
pl.col("created_at").dt.month().alias("month"),
])
.group_by(["country", "month"])
.agg([
pl.col("revenue").sum().alias("total_revenue"),
pl.col("order_id").n_unique().alias("orders"),
pl.col("customer_id").n_unique().alias("customers"),
])
.sort(["country", "month"])
.collect(streaming=True)
)
result.write_parquet("output/monthly_summary.parquet")
Leggilo una volta: scan, filter, derivare due colonne, group, aggregare tre cose, sort, eseguire in streaming, scrivere. Non c’è nessun frame temporaneo, nessuna variabile intermedia, nessun .copy(). L’intero calcolo è una expression, il planner la vede come una expression, e gira veloce quanto il disco e le tue CPU permettono.
Cosa viene dopo
La lezione 36 chiude il modulo con un progetto end-to-end: carica un dataset reale, puliscilo, esploralo, rispondi a una domanda, scrivi il risultato, usando i pattern delle ultime dodici lezioni. Dopo di quella, il modulo 7 è data engineering: come prendere uno script di analisi come quelli che abbiamo scritto e trasformarlo in qualcosa che gira a un orario, gestisce i fallimenti, e non devi fare il babysitter.
Letture di approfondimento
- Guida utente di Polars: la guida ufficiale, con sezioni su lazy evaluation, expression, e migrazione da pandas. Recuperato il 2026-05-01.
- Riferimento dell’API Python di Polars: la lista completa dei metodi. Recuperato il 2026-05-01.
Ci vediamo venerdì per il progetto.