Dopo il rilascio della 1.0 stabile, Polars è cresciuto parecchio. Le versioni 1.x hanno portato un nuovo motore di streaming basato su push-based execution, l'integrazione con Polars Cloud per l'esecuzione distribuita e un'API sempre più vicina agli standard SQL. I vantaggi principali rispetto a pandas, in pillole:
- Parallelismo automatico: ogni espressione sfrutta tutti i core disponibili, senza che tu debba configurare niente.
- Memory efficiency: formato colonnare Arrow, zero-copy tra processi, tipi nativi per le stringhe (finalmente niente Python object).
- Lazy API: il planner ottimizza l'intera query prima di eseguirla — predicate pushdown, projection pushdown, common subexpression elimination.
- Streaming engine: elabora dataset da centinaia di GB con RAM limitata.
- Type safety: schema fortemente tipizzato, che previene quegli errori silenziosi di cast che tutti abbiamo odiato almeno una volta.
Installazione e configurazione
Per installare Polars nel 2026 basta davvero poco:
pip install polars
# Con tutte le feature opzionali (Excel, Parquet cloud, numpy, pandas interop)
pip install 'polars[all]'
# Se ti serve il motore GPU (CUDA)
pip install 'polars[gpu]'
Requisiti: Python 3.9+. Polars supporta nativamente Apple Silicon, Linux x86_64/arm64 e Windows. Non richiede dipendenze C++ esterne come pandas con PyArrow (una rogna in meno quando lavori in Docker).
Verifica rapida che tutto sia a posto:
import polars as pl
print(pl.__version__)
print(pl.show_versions())
Primi passi: DataFrame, Series e lettura file
L'oggetto principale è pl.DataFrame. A differenza di pandas, Polars non ha un indice: ogni riga è identificata dalla sua posizione, e i join si fanno su colonne esplicite. Per chi arriva da pandas è un piccolo adattamento mentale, ma onestamente si rivela liberatorio dopo poche settimane.
import polars as pl
df = pl.DataFrame({
"prodotto": ["Caffè", "Tè", "Cacao", "Zucchero"],
"prezzo": [2.50, 1.80, 3.20, 0.90],
"quantita": [120, 85, 45, 200],
})
print(df)
print(df.schema)
Lettura dei formati più comuni:
# CSV con inferenza automatica dello schema
df = pl.read_csv("vendite.csv")
# Parquet (consigliato in produzione)
df = pl.read_parquet("vendite.parquet")
# JSON newline-delimited
df = pl.read_ndjson("eventi.jsonl")
# Excel (richiede polars[all] o calamine)
df = pl.read_excel("report.xlsx", sheet_name="Gennaio")
# Database tramite connectorx (molto più veloce di pandas.read_sql)
df = pl.read_database_uri(
query="SELECT * FROM ordini WHERE anno = 2026",
uri="postgresql://user:pass@localhost/shop",
)
L'Expression API: il cuore di Polars
La differenza concettuale più grande rispetto a pandas è l'Expression API. In pandas scrivi df["colonna"].operazione() e ogni passaggio viene eseguito subito. In Polars invece costruisci delle espressioni con pl.col() e le passi a metodi come select(), with_columns(), filter(), group_by(). All'inizio sembra strano. Poi non torni più indietro.
import polars as pl
df = pl.read_parquet("vendite.parquet")
risultato = df.select(
pl.col("prodotto"),
pl.col("prezzo"),
(pl.col("prezzo") * pl.col("quantita")).alias("ricavo"),
pl.when(pl.col("prezzo") > 2.0)
.then(pl.lit("premium"))
.otherwise(pl.lit("standard"))
.alias("fascia"),
)
Il vantaggio è che tutte le espressioni all'interno dello stesso select o with_columns vengono eseguite in parallelo. Niente loop, niente apply per la maggior parte delle trasformazioni.
Espressioni condizionali complesse
# Equivalente di np.select in pandas
df = df.with_columns(
pl.when(pl.col("quantita") > 100).then(pl.lit("alto"))
.when(pl.col("quantita") > 50).then(pl.lit("medio"))
.otherwise(pl.lit("basso"))
.alias("volume")
)
Operazioni su stringhe e date
Polars ha namespace dedicati (.str, .dt, .list, .struct) simili a quelli di pandas, ma molto più veloci:
df = df.with_columns(
pl.col("email").str.to_lowercase().str.strip_chars(),
pl.col("email").str.contains("@gmail").alias("is_gmail"),
pl.col("data_ordine").str.to_datetime("%Y-%m-%d").alias("ts"),
)
df = df.with_columns(
pl.col("ts").dt.year().alias("anno"),
pl.col("ts").dt.weekday().alias("giorno_settimana"),
pl.col("ts").dt.truncate("1mo").alias("mese"),
)
Lazy API: l'ottimizzatore di query
Qui è dove Polars brilla davvero. Invece di eseguire subito, costruisci un LazyFrame che descrive l'intera pipeline. Quando chiami .collect(), il query planner analizza tutto, applica le ottimizzazioni, e solo a quel punto esegue. È lo stesso principio dei database, portato dentro Python.
import polars as pl
piano = (
pl.scan_parquet("vendite_2026/*.parquet") # scan invece di read
.filter(pl.col("regione") == "Lombardia")
.filter(pl.col("data").dt.month() == 3)
.group_by("prodotto")
.agg([
pl.col("ricavo").sum().alias("ricavo_totale"),
pl.col("ricavo").mean().alias("ricavo_medio"),
pl.col("ordine_id").n_unique().alias("ordini_distinti"),
])
.sort("ricavo_totale", descending=True)
.head(20)
)
# Mostra il piano ottimizzato
print(piano.explain(optimized=True))
# Esegui
risultato = piano.collect()
Le ottimizzazioni automatiche includono:
- Predicate pushdown: i filtri vengono spostati il più vicino possibile alla lettura, quindi leggi meno dati da disco.
- Projection pushdown: vengono caricate solo le colonne effettivamente usate nella query.
- Common subexpression elimination: le espressioni ripetute sono calcolate una sola volta.
- Slice pushdown: se usi
.head(n) o .tail(n), Polars limita la lettura a monte.
Un consiglio: fai sempre girare explain(optimized=True) almeno una volta su query nuove. Vedere come il planner riscrive il tuo codice è, oltre che utile, anche divertente.
Streaming: dataset più grandi della RAM
Il motore di streaming rilasciato nel 2025 permette di processare dataset che in memoria non ci starebbero mai. Si attiva passando engine="streaming" a collect():
risultato = (
pl.scan_parquet("log_eventi/*.parquet") # 500 GB di log
.filter(pl.col("livello") == "ERROR")
.group_by(["servizio", pl.col("timestamp").dt.truncate("1h")])
.agg(pl.len().alias("errori"))
.collect(engine="streaming")
)
Per dataset enormi puoi anche scrivere direttamente su disco senza materializzare nulla in memoria:
(
pl.scan_parquet("input/*.parquet")
.filter(pl.col("valido") == True)
.sink_parquet("output/cleaned.parquet")
)
Metodi sink disponibili: sink_parquet, sink_csv, sink_ipc, sink_ndjson. L'ho usato su un job da ~300 GB di log Parquet su una macchina con 16 GB di RAM e ha funzionato senza battere ciglio — roba che in pandas non avrei nemmeno tentato.
Group by e aggregazioni parallele
Le aggregazioni in Polars sono parallele di default e supportano espressioni complesse direttamente dentro .agg():
riepilogo = df.group_by("regione").agg([
pl.col("ricavo").sum().alias("ricavo_totale"),
pl.col("ricavo").mean().round(2).alias("ricavo_medio"),
pl.col("cliente_id").n_unique().alias("clienti_unici"),
pl.col("prodotto").mode().first().alias("prodotto_top"),
(pl.col("ricavo") > 1000).sum().alias("ordini_grandi"),
pl.col("ricavo").quantile(0.95).alias("p95"),
])
Group by dinamico su timestamp
Per le serie temporali, group_by_dynamic è l'equivalente (molto più performante) di resample di pandas:
serie = (
df.sort("timestamp")
.group_by_dynamic("timestamp", every="15m", closed="left")
.agg([
pl.col("valore").mean().alias("media"),
pl.col("valore").max().alias("max"),
pl.len().alias("n_campioni"),
])
)
Window function
Polars supporta window function in stile SQL tramite .over(). Niente più groupby().transform() contorto come in pandas:
df = df.with_columns([
pl.col("ricavo").sum().over("cliente_id").alias("ricavo_totale_cliente"),
pl.col("ricavo").rank(method="dense", descending=True).over("regione").alias("rank_regione"),
pl.col("ricavo").cum_sum().over("cliente_id").alias("ricavo_cumulativo"),
(pl.col("ricavo") / pl.col("ricavo").sum().over("regione")).alias("quota_regione"),
])
Join paralleli
I join in Polars sono multi-thread e supportano tutte le strategie standard, più asof per i timestamp non allineati (utilissimo in finanza e IoT):
# Join standard
ordini_con_clienti = ordini.join(
clienti,
on="cliente_id",
how="left", # 'inner', 'left', 'full', 'semi', 'anti', 'cross'
)
# Asof join: abbina ogni riga al record temporale più vicino
prezzi_con_quote = prezzi.sort("timestamp").join_asof(
quote_mercato.sort("timestamp"),
on="timestamp",
by="simbolo",
strategy="backward",
tolerance="1s",
)
Integrazione con l'ecosistema Python
Polars è progettato per coesistere con le librerie esistenti, non per sostituirle. Le conversioni sono zero-copy quando possibile, grazie ad Arrow:
# Da/verso pandas (zero-copy per tipi numerici)
pdf = df.to_pandas(use_pyarrow_extension_array=True)
df_back = pl.from_pandas(pdf)
# Da/verso NumPy
arr = df.select("ricavo").to_numpy()
df_num = pl.DataFrame({"x": arr.flatten()})
# Da/verso PyArrow (zero-copy)
tbl = df.to_arrow()
df_arrow = pl.from_arrow(tbl)
# Integrazione con scikit-learn (usa to_numpy per X, y)
from sklearn.ensemble import RandomForestClassifier
X = df.select(["feature1", "feature2", "feature3"]).to_numpy()
y = df["target"].to_numpy()
model = RandomForestClassifier().fit(X, y)
Dal 2025 scikit-learn accetta direttamente DataFrame Polars tramite il protocollo __dataframe__, quindi in molti casi puoi passarli senza conversione esplicita. Piccolo, ma fa davvero la differenza quando iteri sui modelli.
Benchmark reali: Polars vs pandas nel 2026
Sul TPC-H benchmark (scale factor 10, circa 10 GB di dati) eseguito su una macchina a 16 core, le performance tipiche osservate nel 2026 sono queste:
- Query analitica semplice (filter + group by + sort): Polars 8-15x più veloce.
- Join multi-tabella: Polars 10-25x più veloce.
- Window function: Polars 15-40x più veloce.
- Uso di memoria: tipicamente 2-5x inferiore, grazie al formato colonnare e all'assenza di Python object.
I benchmark ufficiali sono disponibili nel repository pola-rs/tpch e vengono rieseguiti automaticamente a ogni release. Il mio consiglio, però, è sempre lo stesso: misura sul tuo workload. I numeri generali ti danno un'idea, ma la realtà del tuo schema e dei tuoi filtri può cambiare parecchio la storia.
Migrazione da pandas: cheatsheet pratico
Qui sotto le equivalenze più comuni che ti serviranno quando inizierai a migrare.
Selezione e filtri
# pandas
pdf[pdf["prezzo"] > 10][["prodotto", "prezzo"]]
# Polars
df.filter(pl.col("prezzo") > 10).select(["prodotto", "prezzo"])
Creazione di colonne
# pandas
pdf["ricavo"] = pdf["prezzo"] * pdf["quantita"]
pdf["tasse"] = pdf["ricavo"] * 0.22
# Polars (una sola passata, parallela)
df = df.with_columns([
(pl.col("prezzo") * pl.col("quantita")).alias("ricavo"),
(pl.col("prezzo") * pl.col("quantita") * 0.22).alias("tasse"),
])
Group by
# pandas
pdf.groupby("regione")["ricavo"].agg(["sum", "mean", "count"])
# Polars
df.group_by("regione").agg([
pl.col("ricavo").sum(),
pl.col("ricavo").mean(),
pl.len().alias("count"),
])
Gestione valori mancanti
# pandas
pdf["prezzo"].fillna(pdf["prezzo"].median())
pdf.dropna(subset=["cliente_id"])
# Polars
df.with_columns(pl.col("prezzo").fill_null(pl.col("prezzo").median()))
df.drop_nulls(subset=["cliente_id"])
Apply → espressioni native
La regola d'oro, detta una volta e da tenere a mente per sempre: evita map_elements (l'equivalente di apply). Quasi sempre esiste un'espressione nativa più veloce di 100-1000x. Davvero. Se stai scrivendo map_elements, fermati un attimo e cerca l'alternativa.
# pandas (lento)
pdf["tipo"] = pdf["email"].apply(lambda x: "gmail" if "@gmail" in x else "altro")
# Polars (veloce, vettoriale)
df = df.with_columns(
pl.when(pl.col("email").str.contains("@gmail"))
.then(pl.lit("gmail"))
.otherwise(pl.lit("altro"))
.alias("tipo")
)
Strategia di migrazione in produzione
Per migrare una codebase pandas esistente senza mandare tutto in fumo, questo è l'approccio in 4 fasi che consiglio e che ho già visto funzionare bene in più team:
- Identifica i colli di bottiglia: profila il codice pandas con
line_profiler o memory_profiler. Concentrati sulle funzioni che consumano il 20% superiore di tempo o memoria — lì sta il vero guadagno.
- Sostituisci punto per punto: porta singole funzioni a Polars, mantenendo l'interfaccia pandas esterna tramite
pl.from_pandas() e .to_pandas(). Questo permette PR piccole e test A/B immediati.
- Unifica l'I/O: fai in modo che i confini del sistema (lettura file, scrittura database) usino Polars. È qui che ottieni la maggior parte dei guadagni, senza dover riscrivere il cuore della logica.
- Adotta l'API lazy: quando le singole query sono stabili, unisci più step in un'unica pipeline
LazyFrame per sfruttare le ottimizzazioni globali.
Test di regressione: usa polars.testing.assert_frame_equal confrontando l'output Polars con quello pandas sullo stesso input. Attenzione alle differenze attese (ordinamento di group_by, gestione di NaN vs Null) — sono le prime cose su cui inciampa chi fa questo salto.
Quando non usare Polars
Polars non è una bacchetta magica. Continua a usare pandas quando:
- Il tuo dataset è piccolo (meno di 100k righe) e le performance non sono un problema.
- Dipendi da librerie che accettano solo pandas (alcune librerie di visualizzazione legacy, certi pacchetti statistici un po' datati).
- Hai bisogno di un
MultiIndex complesso (Polars usa struct column al posto del multiindex).
- Il team non ha tempo di imparare l'Expression API, e la flessibilità di pandas pesa più della velocità.
Per carichi distribuiti oltre il singolo nodo, valuta Dask, Ray o Polars Cloud, che estende l'API di Polars su cluster remoti.
Pattern avanzati che valgono la migrazione
Pipe per pipeline riutilizzabili
def aggiungi_fascia_prezzo(df: pl.DataFrame, soglia: float) -> pl.DataFrame:
return df.with_columns(
pl.when(pl.col("prezzo") > soglia)
.then(pl.lit("premium"))
.otherwise(pl.lit("standard"))
.alias("fascia")
)
def filtra_attivi(df: pl.DataFrame) -> pl.DataFrame:
return df.filter(pl.col("stato") == "attivo")
risultato = (
df.pipe(filtra_attivi)
.pipe(aggiungi_fascia_prezzo, soglia=10.0)
)
Lettura multi-file con partition pruning
# Se i file sono partizionati per data (dt=2026-04-24/...)
df = (
pl.scan_parquet("data/dt=*/*.parquet", hive_partitioning=True)
.filter(pl.col("dt").is_between("2026-04-01", "2026-04-30"))
.collect()
)
Con hive_partitioning=True, Polars legge solo le partizioni che soddisfano il filtro: un risparmio enorme quando lavori su un data lake. Credimi, la prima volta che lo vedi in azione su un dataset da terabyte è piuttosto soddisfacente.
FAQ
Polars sostituirà pandas?
No, non nel breve periodo. pandas ha un ecosistema maturissimo (25+ anni di librerie costruite sopra) e resta la scelta giusta per l'analisi esplorativa su dataset piccoli, o quando l'interoperabilità con librerie legacy è critica. Polars, d'altra parte, ha vinto il terreno delle pipeline dati ad alte prestazioni e dei carichi analitici in produzione. La situazione tipica nel 2026 è una convivenza: Polars per l'ingestion e le trasformazioni pesanti, pandas per l'analisi one-off.
Polars supporta GPU come RAPIDS cuDF?
Sì. Dal 2025 è disponibile il backend GPU tramite integrazione con cuDF, attivabile con pl.Config.set_streaming_chunk_size() e collect(engine="gpu"). Richiede CUDA 12+ e una GPU NVIDIA con almeno 8 GB di VRAM. I guadagni tipici sono 3-10x rispetto alla versione CPU, su aggregazioni di dataset grandi.
Come gestisco gli indici se arrivo da pandas?
Polars non ha indice: ogni riga è identificata dalla posizione. Se il tuo workflow pandas dipende dall'indice, hai due opzioni: (1) promuoverlo a colonna esplicita con pdf.reset_index() prima della conversione, oppure (2) usare with_row_index() in Polars per aggiungere un contatore. La maggior parte delle operazioni basate su indice (join, allineamento) si esprime in modo più pulito in Polars usando .join() con chiavi esplicite.
Lazy o eager: quale scegliere?
Eager per l'analisi interattiva (Jupyter, REPL), dove vuoi vedere i risultati intermedi. Lazy per le pipeline di produzione, ETL, job schedulati: il planner elimina colonne e filtri inutili, riduce l'I/O e parallelizza al meglio. Puoi sempre convertire tra i due con .lazy() e .collect(), quindi la decisione non è mai definitiva.
Polars gestisce bene le stringhe Unicode e le lingue con caratteri speciali?
Sì. Il tipo stringa nativo di Polars è UTF-8 e tutte le funzioni del namespace .str sono consapevoli dei grapheme cluster (str.len_chars() conta caratteri, non byte). Per operazioni avanzate di normalizzazione (NFC, NFD) puoi usare str.normalize(). Le performance sulle stringhe sono tipicamente 5-10x superiori rispetto a pandas, che internamente usa oggetti Python.
Conclusione
Nel 2026, adottare Polars significa ridurre drasticamente i tempi di esecuzione e i costi infrastrutturali delle pipeline dati Python, senza rinunciare alla leggibilità. L'investimento di tempo per imparare l'Expression API è contenuto — una settimana di pratica per un team già proficient in pandas — e ripaga quasi subito in produzione.
Il consiglio pratico, dall'esperienza sul campo: inizia migrando un solo batch job pesante, misura i guadagni, e poi espandi per fasi. Mantieni pandas dove serve davvero e non forzare una migrazione totale solo per l'hype. La combinazione Polars per l'ETL + pandas per l'analisi esplorativa + scikit-learn per il ML è, di fatto, lo stack standard del 2026 per data scientist italiani pragmatici. Buone analisi.