Hai scritto uno script pandas. Fa la cosa giusta sul campione di test. Poi lo punti sul file vero e te ne vai a farti un caffè, e quando torni sta ancora girando. O peggio, sta girando sul portatile di un collega e ti stanno chiedendo gentilmente quanto dovrebbe metterci.
Questa lezione è il loop diagnostico per la performance di pandas: come capire perché è lento, le cinque cose che sono quasi sempre la causa, e il soffitto pragmatico al quale la risposta giusta smette di essere “ottimizza il codice pandas” e inizia a essere “riscrivi in Polars o DuckDB”. Abbiamo visto il lato dtype di questo nella lezione 33 (categorical, string[pyarrow], int nullable); oggi lo mettiamo nella cornice più ampia.
La lentezza di pandas ha cinque sapori
Nella mia esperienza ci sono davvero solo cinque ragioni per cui pandas è lento, e una volta che le conosci, ogni “questo script ci mette 40 minuti” si trasforma in “ah, è il sapore 2”. Sono, in ordine grossolano di quanto spesso le vedo:
- Dtype sbagliati. Una colonna che dovrebbe essere
categoryèobject. Una colonna di interi è stata promossa afloat64per via di un NaN. Una colonna di stringhe da 5 milioni di righe vive come stringheobjectPython invece che comestring[pyarrow]. La memoria esplode, ogni operazione è più lenta del dovuto, e il GC inizia a fare thrashing. apply()con una lambda Python. Questo è quello grosso.df["col"].apply(lambda x: ...)è un cicloforPython travestito: ogni riga attraversa il confine C/Python, il GIL è tenuto, e hai buttato via ogni vantaggio di usare un DataFrame in primo luogo.- Lavorare sull’intero DataFrame quando ti serve solo una slice. Caricare 200 colonne per usarne 6. Filtrare dopo un groupby invece che prima. Leggere 10 milioni di righe in memoria per calcolare un numero.
- Operazioni single-thread su dati che si parallelizzerebbero. Pandas, per design, gira su un core. Se il tuo portatile ha 16 core e stai macinando per un’ora, quindici di loro si stanno annoiando.
- Troppi dati per una macchina. Questo è il muro. Pandas ha bisogno grosso modo di 5-10 volte la dimensione dei dati in RAM durante le operazioni (copie intermedie, join, buffer di groupby). Per 1 GB di dati su una macchina da 32 GB stai bene. Per 50 GB di dati su una macchina da 64 GB no, e nessuna quantità di tuning ti salverà.
Il loop diagnostico mappa su questi uno a uno. Eseguiamolo.
Il loop diagnostico
In un notebook Jupyter (o ipython), il workflow è:
%%timeit
result = df.groupby("country")["revenue"].transform(lambda s: s - s.mean())
%%timeit esegue la cella diverse volte e ti dà un numero stabile di wall-clock. Usalo su qualsiasi riga sia sospetta. Se l’intero script è lento, commenta cose finché non trovi il colpevole; nove volte su dieci è una singola riga.
Una volta che hai la riga lenta, controlla tre cose in ordine:
df.info(memory_usage="deep")
Questo ti mostra ogni colonna, il suo dtype, e la sua impronta in memoria. Cerca:
- Dtype
objectche dovrebbero esserecategoryostring[pyarrow]. - Colonne
float64che sono concettualmente interi (probabilmente hanno acquisito un NaN da qualche parte a monte). - Qualsiasi colonna dove la memoria deep è selvaggiamente sproporzionata: una colonna stringa da 5 milioni di righe a 1,5 GB è il tuo collo di bottiglia.
Poi guarda l’operazione stessa:
- È
.applycon una lambda Python? Quasi certamente può essere vettorizzata. - È un
groupbysu una colonna che è ancora dtypeobject? Castala acategoryprima; il groupby su categorical è diverse volte più veloce. - Stai copiando il frame più di una volta? Ogni
.copy()e molte chiamate.assignconcatenate allocano.
Quel triage di solito identifica la leva. Ora, le cinque leve stesse.
Leva 1: dtype migliori
Questa è la vittoria più economica. Casta le colonne string object a bassa cardinalità a category; casta tutto il resto a string[pyarrow]; riporta le colonne intere che sono andate alla deriva verso float a Int64 nullable. Uno script tipico applicato a 5 milioni di righe di dati clienti scende da 4 GB di memoria residente a 600 MB, e i groupby vanno 5-10 volte più veloci, tutto da una passata in cima allo script:
df = df.astype({
"country": "category",
"currency": "category",
"tier": "category",
"customer_id": "string[pyarrow]",
"name": "string[pyarrow]",
"order_id": "Int64",
})
O, molto meglio, dichiaralo in lettura così non paghi mai la tassa dtype-sbagliato in primo luogo (dtype= su read_csv, o usa direttamente Parquet, che porta i dtype nativamente).
Leva 2: vettorizza
Se hai un’ottimizzazione da imparare da questa lezione, impara questa. apply con una lambda Python è la cosa più lenta in pandas, e quasi sempre può essere sostituita da qualcosa da venti a cento volte più veloce.
Il pattern è: qualunque cosa faccia la tua lambda, trova l’operazione equivalente a livello di colonna e chiama quella. Esempi concreti.
Colonna condizionale. Spesso si scrive:
df["band"] = df["age"].apply(lambda x: "young" if x < 30 else "old")
Sostituisci con np.where o pd.cut:
import numpy as np
df["band"] = np.where(df["age"] < 30, "young", "old")
Per band multiple, pd.cut o np.select:
df["band"] = pd.cut(df["age"], bins=[0, 18, 30, 60, 200], labels=["minor", "young", "adult", "senior"])
Entrambi sono vettorizzati, entrambi sono veloci, entrambi girano in C.
Operazioni stringa. Si scrive:
df["domain"] = df["email"].apply(lambda s: s.split("@")[1].lower())
Pandas ha un accessor stringa proprio per questo:
df["domain"] = df["email"].str.split("@").str[1].str.lower()
L’accessor .str mappa su kernel stringa vettorizzati. Con dtype string[pyarrow], quei kernel girano nel codice stringa compilato di Arrow e sono grosso modo dieci volte più veloci dell’equivalente Python.
Matematica su più colonne. Si scrive:
df["score"] = df.apply(lambda r: r["a"] * 0.5 + r["b"] * 0.3 + r["c"] * 0.2, axis=1)
Questo è axis=1, la peggior forma di apply perché itera riga per riga attraverso Python. Vettorizza:
df["score"] = df["a"] * 0.5 + df["b"] * 0.3 + df["c"] * 0.2
Cento volte più veloce. Non c’è essenzialmente nessuna scusa per df.apply(..., axis=1) nel 2026; se ti ritrovi a allungare la mano, fermati e pensa a quale operazione a livello di colonna farebbe la stessa cosa.
L’unico posto legittimo per apply è quando il calcolo per riga è genuinamente complesso (chiama un’API, esegui un modello, fai qualcosa che il mondo vettorizzato non può esprimere). E anche allora: fai batch, eseguilo una volta sui valori unici, e ricongiungi.
Leva 3: letture a chunk
Se i tuoi dati non entrano in memoria ma l’operazione è per riga o aggregabile, fai streaming. Ogni pd.read_* accetta chunksize=:
totals: dict[str, float] = {}
for chunk in pd.read_csv("huge.csv", chunksize=500_000, dtype_backend="pyarrow"):
for country, sub in chunk.groupby("country"):
totals[country] = totals.get(country, 0) + sub["revenue"].sum()
Ogni chunk è un DataFrame normale; lo processi e vai avanti. La memoria resta limitata alla dimensione di un chunk, indipendentemente dalla dimensione del file. Funziona per somme, conteggi, top-K, qualsiasi cosa aggregabile. Non funziona per operazioni che hanno bisogno di vedere l’intero frame (ordinare l’intero file, calcolare un percentile globale); per quelle, leva 5.
Leva 4: query() e eval()
Per lunghe catene aritmetiche, df.eval e df.query usano numexpr sotto il cofano, che valuta l’intera espressione in C, in parallelo, senza allocazioni intermedie:
# Invece di
df["score"] = (df["a"] + df["b"]) * df["c"] - df["d"] / df["e"]
# Usa
df = df.eval("score = (a + b) * c - d / e")
# Invece di
result = df[(df["age"] > 30) & (df["country"] == "IT") & (df["revenue"] > 1000)]
# Usa
result = df.query("age > 30 and country == 'IT' and revenue > 1000")
Lo speedup è modesto (tipicamente 2-3 volte) e perdi un po’ di aiuto dall’IDE, quindi non allungare la mano per espressioni corte. Per catene di cinque o più operazioni su frame grossi vale la pena.
Leva 5: lascia pandas
A volte la risposta giusta non è ottimizzare il codice pandas; è ammettere che il problema ha superato lo strumento. Le due uscite principali nel 2026 sono:
- Polars per lo stesso lavoro DataFrame in-memory ma con un core Rust, multithreading, e un query optimizer. L’API lazy può eseguire query su dati che non entrano in memoria via streaming. Questo è l’argomento della lezione 35.
- DuckDB per query analitiche stile SQL contro file Parquet o frame pandas.
duckdb.query("SELECT country, SUM(revenue) FROM 'data/*.parquet' GROUP BY 1").to_df()gira a grosso modo la velocità di un database column-store, sul tuo portatile, con zero setup.
Un’euristica grossolana: se i tuoi dati entrano comodamente in RAM (sotto, diciamo, 5 GB su una macchina da 32 GB) e lo script gira in meno di un minuto, resta con pandas. Se lo script ci mette 10+ minuti e hai già tirato le leve 1-4, cambia. Se i dati non entrano affatto, non hai scelta: Polars in modalità streaming o DuckDB.
Sugli assistenti AI e l’ottimizzazione di pandas
Questo è uno dei casi in cui gli assistenti AI sono affidabilmente utili. Incolla una funzione lenta in Claude o ChatGPT con il prompt “questa è lenta, trova il collo di bottiglia e riscrivila” e i suggerimenti di solito sono giusti: cambia apply in np.where, casta questa colonna a category, sostituisci il merge con un join sull’index. Il modello ha visto diecimila risposte pandas su Stack Overflow e i pattern sono meccanici.
Dove gli assistenti sono meno affidabili è la domanda architetturale: quando abbandonare pandas del tutto. Ottimizzeranno volentieri uno script che dovrebbe essere una query Polars o una chiamata DuckDB, facendoti guadagnare 3 volte quando una riscrittura ti farebbe guadagnare 30 volte. Quel giudizio è ancora tuo, e l’euristica qui sopra ne è l’inizio.
Memoria: il muro
La regola del cinque-a-dieci-volte vale la pena di essere ripetuta perché sorprende le persone. Un CSV da 1 GB diventa un DataFrame in memoria da grosso modo 1,5-3 GB (i dtype pandas sono più larghi dei byte CSV, ma le stringhe Arrow-backed restringono questo). Un groupby su di esso alloca buffer intermedi che raddoppiano o triplicano questo. Un merge con un altro frame può allocare il prodotto cartesiano. Quindi:
- 1 GB di dati, 32 GB di RAM: comodo.
- 5 GB di dati, 32 GB di RAM: stretto, specialmente con i merge.
- 10 GB di dati, 32 GB di RAM: andrai in swap; la performance crolla.
- 10 GB di dati, 64 GB di RAM: lavorabile.
- 50 GB di dati, qualsiasi portatile: non pandas.
Il muro è reale e non è un problema di configurazione: è il costo di tenere tutto in RAM contemporaneamente. La via d’uscita è o un motore di streaming (scan_* di Polars con collect(streaming=True), DuckDB) o un motore distribuito (Spark, Dask), e vedremo il primo nella prossima lezione.
Cosa viene dopo
La lezione 35 è il capitolo Polars: cosa fa di diverso, perché l’API lazy è il punto vero, e come leggerla se già pensi in pandas. Dopo di quella, la lezione di progetto, e poi il modulo 6 si chiude.
Letture di approfondimento
- pandas: Enhancing performance: guida ufficiale a
eval,query, e vie di fuga via Cython. Recuperato il 2026-05-01. - pandas: Scaling to large datasets: chunking, trucchi di dtype, quando andarsene. Recuperato il 2026-05-01.
Ci vediamo martedì.