Nel 2026 Polars non è più una scommessa da early adopter: è diventata — a tutti gli effetti — la libreria DataFrame di riferimento per chi lavora con dataset medio-grandi in Python. Scritta in Rust, costruita sopra Apache Arrow e pensata fin dal primo giorno per il parallelismo nativo, Polars riesce a essere fino a 10-30 volte più veloce di pandas sulle operazioni analitiche più comuni. E, cosa non scontata, mantiene un'API che resta leggibile.
Questa guida pratica copre tutto quello che serve per portare Polars in produzione quest'anno: installazione, API eager e lazy, motore di streaming per dataset più grandi della RAM, window function, join paralleli, interop con pandas/NumPy/PyArrow e una strategia di migrazione passo-passo da codice pandas esistente. Niente fuffa, solo roba che userai davvero.
Perché Polars nel 2026
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_profileromemory_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
LazyFrameper 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
MultiIndexcomplesso (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.