Hai un DataFrame. Adesso ne vuoi un pezzo: una colonna, qualche riga, le righe dove vale qualche condizione, la cella alla riga 7 colonna 3. Pandas ti dà tre modi per farlo, e si somigliano abbastanza da farli mescolare tra loro ai principianti finché qualcosa non funziona. Il risultato è codice che gira oggi e si rompe la prossima settimana quando l’index cambia. Oggi sbroglieremo i tre idiomi, parentesi quadre, .loc, .iloc, e alla fine guarderemo il SettingWithCopyWarning, il messaggio più cercato su Google nel mondo pandas.
Lavoreremo con un DataFrame di esempio per tutta la lezione:
import pandas as pd
df = pd.DataFrame(
{
"country": ["IT", "DE", "FR", "IT", "DE", "FR"],
"year": [2024, 2024, 2024, 2025, 2025, 2025],
"revenue": [120.0, 340.0, 210.0, 145.0, 380.0, 225.0],
"orders": [12, 25, 18, 15, 28, 19],
}
)
Sei righe, quattro colonne, index intero di default. Abbastanza semplice da ragionarci sopra, abbastanza reale da essere utile.
Le parentesi quadre, comode e ambigue
La sintassi nuda df[...] è quella che la gente impara per prima perché assomiglia a un dict Python. Fa quattro cose diverse a seconda di cosa ci metti dentro:
df["revenue"] # una colonna -> Series
df[["country", "revenue"]] # lista di colonne -> DataFrame
df[0:3] # slice di righe -> DataFrame (prime 3 righe)
df[df["revenue"] > 200] # boolean mask -> DataFrame (righe filtrate)
Quattro operazioni diverse che condividono una sintassi. È comodo nella REPL: non devi ricordarti quale usare, scrivi qualcosa e pandas lo capisce. È anche ambiguo: se hai una colonna che si chiama letteralmente 0, o un DataFrame con index a stringhe, il significato di df[0:3] cambia. Per analisi una tantum le parentesi quadre vanno bene. Per codice che rileggerai fra sei mesi, preferisci .loc o .iloc, che ognuno significa esattamente una cosa.
L’altro motivo per evitare le parentesi quadre nelle pipeline è il chaining. df[df["revenue"] > 200]["orders"] = 0 sembra che dovrebbe mettere orders a zero sulle righe ad alto fatturato. Non lo fa, in modo affidabile. Tra un attimo torniamo sul perché.
.loc, selezione per label
.loc indicizza per label: per il valore dell’index sulle righe, per il valore del nome della colonna sulle colonne. Due argomenti, separati da una virgola:
df.loc[row_labels, col_labels]
Per il nostro index intero di default, le label delle righe coincidono con le posizioni delle righe: df.loc[2] è la terza riga perché la sua label è 2. Quell’equivalenza è la trappola: funziona finché non fai df = df.sort_values("revenue") e adesso df.loc[2] è dovunque la riga con label originale 2 sia finita, non la terza riga. Pensa sempre a .loc come “per label”, anche quando le label sembrano posizioni.
Forme concrete:
df.loc[0] # riga con label 0 (Series)
df.loc[[0, 2, 4]] # righe con queste label (DataFrame)
df.loc[1:3] # righe da label 1 a label 3 INCLUSE
df.loc[:, "revenue"] # tutte le righe, una colonna (Series)
df.loc[:, ["country", "revenue"]] # tutte le righe, due colonne (DataFrame)
df.loc[0:2, "country":"revenue"] # slice rettangolare, entrambi gli estremi inclusi
Due cose da interiorizzare:
Le slice sono inclusive a entrambi gli estremi. df.loc[1:3] restituisce le label 1, 2 e 3. È il contrario di ogni altro slice Python che hai mai visto, ed è voluto: con valori di index arbitrari (stringhe, date), “fermati appena prima di questa label” è raramente quello che vuoi. La prima volta sorprende comunque.
Le boolean mask vivono sull’asse delle righe. Questo è lo schema da produzione:
mask = df["revenue"] > 200
df.loc[mask, "orders"] # colonna orders per le righe ad alto fatturato
df.loc[mask, ["country", "orders"]] # due colonne per le righe ad alto fatturato
df.loc[mask, "orders"] = 0 # ASSEGNAZIONE (questa e' la forma sicura)
La maschera deve essere una Series booleana allineata all’index delle righe. .loc la accetta posizionalmente, primo argomento, asse delle righe, e puoi combinarla con una label di colonna o una lista sul secondo asse. Questa è la forma da memorizzare; è così che dovrebbe apparire l’80% del filtraggio del mondo reale.
.iloc, selezione per posizione
.iloc è l’altra. Stessa forma, regole diverse: indicizza per posizione intera, non per label. Le slice si comportano come le slice Python normali (semi-aperte):
df.iloc[0] # prima riga (Series)
df.iloc[-1] # ultima riga
df.iloc[0:3] # prime 3 righe (lo stop e' esclusivo, come in una list)
df.iloc[:, 0] # prima colonna (Series)
df.iloc[0:3, 0:2] # prime 3 righe, prime 2 colonne
df.iloc[[0, 2, 4]] # righe a queste posizioni
Usa .iloc quando intendi davvero “la terza riga” indipendentemente dalla sua label: la prima riga di una vista ordinata, le ultime 100 righe, una riga sì e una no. Usa .loc quando intendi un valore con label specifica, incluse tutte le boolean mask.
Una regola utile: .iloc per la plumbing posizionale (head, tail, sampling), .loc per tutto ciò che riguarda cosa c’è nei dati.
Indicizzazione booleana, il filtro di tutti i giorni
Una Series booleana della stessa lunghezza dell’asse delle righe diventa un filtro per le righe:
df.loc[df["revenue"] > 200]
df.loc[df["country"] == "IT"]
df.loc[df["country"].isin(["IT", "FR"])]
df.loc[df["revenue"].between(150, 300)]
df.loc[~df["country"].isin(["DE"])] # ~ e' "not"
Combina le maschere con & (and), | (or), ~ (not). Usa le parentesi, perché & lega più strettamente di > e altrimenti otterrai un errore confondente:
df.loc[(df["revenue"] > 200) & (df["country"] == "DE")]
df.loc[(df["country"] == "IT") | (df["country"] == "FR")]
Se ti dimentichi le parentesi, pandas solleva un TypeError che parla di operazioni bit a bit su bool. Quello è il messaggio; la soluzione sono sempre le parentesi.
Per NaN, i predicati delle maschere hanno helper specifici:
df.loc[df["revenue"].isna()]
df.loc[df["revenue"].notna()]
Non usare df["revenue"] == None o df["revenue"] != float("nan"): NaN non è uguale a niente, neanche a se stesso. isna/notna è l’unico modo corretto.
.query, quando le maschere diventano rumorose
Se stai impilando tre o quattro condizioni, le parentesi diventano noiose. df.query accetta una stringa con sapore SQL:
df.query("revenue > 200 and country == 'DE'")
df.query("country in ['IT', 'FR'] and year == 2025")
Qualche regola:
- I letterali stringa vanno tra apici singoli (perché tutta l’espressione è tra apici doppi).
- I nomi di colonna con spazi o punti vogliono i backtick:
df.query("order count> 5"). - Riferisciti alle variabili Python con
@:threshold = 200; df.query("revenue > @threshold").
.query è una vera vittoria sulla leggibilità per filtri di media taglia. Per filtri di una sola condizione, .loc[mask] è più corto; per filtri a molte condizioni, .query si legge come l’SQL che l’analista sta comunque cercando di scrivere.
Impostare valori, lo schema giusto
Leggere è solo metà della storia. Scrivere è dove iniziano i warning.
Lo schema giusto è df.loc[mask, col] = value:
df.loc[df["revenue"] > 300, "country"] = "DE-PREMIUM"
df.loc[df["country"] == "IT", "orders"] *= 2
df.loc[df["revenue"].isna(), "revenue"] = 0
Una sola chiamata a .loc, due argomenti, assegnazione. Pandas sa che è una scrittura, muta il DataFrame originale in place, e non c’è ambiguità su se stai modificando una vista o una copia.
Lo schema sbagliato, quello che produce il warning famoso, è il chained indexing:
df[df["revenue"] > 300]["country"] = "DE-PREMIUM" # NO
Questo chiama df[df["revenue"] > 300] (che restituisce un nuovo DataFrame filtrato, forse una vista, forse una copia) e poi assegna a ["country"] su quello. Pandas non sa dire se la tua scrittura sta colpendo il df originale o una copia temporanea che sta per essere garbage-collected. Storicamente, a volte funzionava, a volte no, a seconda del layout dei dtype. Quindi pandas lanciava SettingWithCopyWarning e incrociava le dita.
SettingWithCopyWarning, in breve
Il testo del warning è notoriamente confondente:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer, col_indexer] = value instead.
Cosa significa: hai usato chained indexing, e pandas non è sicuro che la tua assegnazione sia andata a buon fine. La soluzione è quella che il warning ti dice letteralmente: fai collassare la catena in un singolo .loc[mask, col] = value.
La causa profonda: gli array NumPy stanno dietro i DataFrame, e fare slice di un array NumPy a volte restituisce una vista (le scritture si propagano all’originale) e a volte restituisce una copia (le scritture no). Pandas non poteva dirlo dall’interno, quindi avvisava in via difensiva ogni volta che la struttura sembrava pericolosa.
La soluzione di pandas 2.0+: copy-on-write. Pandas ha ora una modalità copy-on-write che risolve l’ambiguità garantendo che ogni operazione si comporti come se restituisse una copia: le scritture vanno sempre all’oggetto su cui hai scritto, mai silenziosamente al suo genitore. A partire da pandas 2.2 è opt-in tramite pd.set_option("mode.copy_on_write", True); in pandas 3.0 diventa il default. Nel 2026, accenderla in cima a qualsiasi progetto nuovo è la scelta giusta:
import pandas as pd
pd.set_option("mode.copy_on_write", True)
Con CoW attivo, lo schema del chained indexing semplicemente non scrive da nessuna parte di utile (modifica la copia usa-e-getta e l’originale resta intoccato), e pandas emette un ChainedAssignmentError invece di un warning, molto più difficile da ignorare. In ogni caso, lo schema giusto non cambia: df.loc[mask, col] = value.
Aggiungere colonne
Due forme equivalenti:
df["revenue_per_order"] = df["revenue"] / df["orders"]
df.loc[:, "revenue_per_order"] = df["revenue"] / df["orders"]
Per la creazione incondizionata di colonne la forma con le parentesi quadre va bene ed è idiomatica: non c’è catena, solo una singola assegnazione. Per la creazione condizionata di colonne (imposta un valore solo su alcune righe), usa .loc:
df["tier"] = "standard"
df.loc[df["revenue"] > 300, "tier"] = "premium"
Oppure, per molti rami, np.select:
import numpy as np
df["tier"] = np.select(
[df["revenue"] > 300, df["revenue"] > 150],
["premium", "mid"],
default="standard",
)
np.select è l’if/elif/else a più rami per l’assegnazione vettorizzata.
Mettiamo insieme i pezzi
Una pipeline di filtraggio reale assomiglia a qualcosa così:
import pandas as pd
pd.set_option("mode.copy_on_write", True)
df = pd.read_parquet("sales.parquet")
# Filtra alle righe che ci interessano.
df = df.loc[
(df["country"].isin(["IT", "DE", "FR"]))
& (df["year"] == 2025)
& df["revenue"].notna()
]
# Aggiungi una colonna derivata.
df["revenue_per_order"] = df["revenue"] / df["orders"]
# Aggiusta condizionatamente alcune righe.
df.loc[df["country"] == "IT", "revenue"] *= 1.05 # correzione del 5% per IT
# Scegli le colonne che vogliamo per il downstream.
result = df.loc[:, ["country", "revenue", "orders", "revenue_per_order"]]
Ogni operazione è una sola chiamata a .loc, niente catene, nessun warning. Rileggilo: filtra, aggiungi, aggiusta, proietta. È tutto il vocabolario.
Cosa c’è dopo
La lezione 29 è groupby e aggregazione: una volta che sai scegliere le righe che vuoi, la cosa successiva che vuoi è riassumerle per gruppo. Copriremo le aggregazioni nominate, la distinzione agg/apply/transform (che frega quasi tutti), e le pivot table.
Letture di approfondimento
- pandas: Indexing and selecting data, il riferimento canonico, lungo ma vale una lettura lenta.
- pandas: Copy-on-write, il nuovo modello, perché esiste, cosa cambia.
- pandas:
query, la grammatica completa delle espressioni.
Ci vediamo martedì.