Ogni utente confuso di pandas che ho aiutato al lavoro era confuso allo stesso livello. Non sulla superficie dell’API, i docs li sapevano leggere, ma a livello di cosa siano questi oggetti. Avevano la vaga sensazione che df["col"] ti dia “una colonna”, che df.loc[5] ti dia “una riga”, che ci sia qualcosa chiamato “indice” che per lo più sembra fastidioso. Il modello nella loro testa era “una tabella 2D, come Excel”. Quando quel modello falliva, e fallisce nel momento in cui fai un’operazione aritmetica tra due slice che non hanno lo stesso ordine di righe, rimbalzavano e si attaccavano a df.values per scappare in NumPy, che è il posto dove le intuizioni competenti vanno a morire.
Pandas ha due strutture dati fondamentali: Series e DataFrame. Sono semplici. Oggi le spieghiamo. Il resto del Modulo 5 avrà allora senso.
Series: un array etichettato 1-D
Una Series è un array unidimensionale di valori, più un array unidimensionale di etichette (l’indice) della stessa lunghezza. Tutto qui. Ogni valore ha un’etichetta, e l’etichetta è ciò che rende pandas diverso da NumPy.
import pandas as pd
s = pd.Series([10, 20, 30, 40])
print(s)
0 10
1 20
2 30
3 40
dtype: int64
La colonna di sinistra (0, 1, 2, 3) è l’indice. Quando non ne specifichi uno, pandas te ne fa uno di default, un RangeIndex da 0. La colonna di destra sono i valori. La riga dtype in fondo ti dice il tipo di quei valori.
Puoi specificare il tuo indice: stringhe, date, qualsiasi cosa hashable:
revenue = pd.Series(
[120, 135, 148, 162],
index=["Q1", "Q2", "Q3", "Q4"],
name="revenue_2025",
)
print(revenue)
Q1 120
Q2 135
Q3 148
Q4 162
Name: revenue_2025, dtype: int64
Puoi costruirne una da un dict, le chiavi diventano automaticamente l’indice:
revenue = pd.Series({"Q1": 120, "Q2": 135, "Q3": 148, "Q4": 162})
L’indice è la killer feature, ed è anche ciò che rende pandas sorprendente. Le operazioni tra Series si allineano per etichetta, non per posizione. Guarda:
revenue_2025 = pd.Series({"Q1": 120, "Q2": 135, "Q3": 148, "Q4": 162})
revenue_2024 = pd.Series({"Q2": 100, "Q3": 110, "Q4": 125, "Q1": 95})
growth = revenue_2025 - revenue_2024
print(growth)
Q1 25
Q2 35
Q3 38
Q4 37
dtype: int64
Nota che revenue_2024 aveva i suoi trimestri in un ordine diverso. Non è importato. Pandas ha accoppiato Q1 con Q1 e così via, per etichetta. Se avessi fatto la stessa cosa in NumPy, avresti ottenuto un risultato senza senso, Q1 meno Q2, Q2 meno Q3, in silenzio. Questo è il compromesso che pandas fa: un oggetto leggermente più complesso, in cambio di un’aritmetica che significa quello che volevi.
Cosa succede con etichette non corrispondenti?
a = pd.Series({"x": 1, "y": 2})
b = pd.Series({"y": 10, "z": 20})
print(a + b)
x NaN
y 12.0
z NaN
dtype: float64
Le etichette non presenti in entrambe le Series producono NaN. Il dtype è stato promosso a float64 perché gli interi non possono essere NaN in pandas con backend NumPy, una insidia famosa che sistemeremo quando discuteremo il backend Arrow tra un attimo.
Dtype: che tipo di valori
Ogni Series ha un dtype, e quale hai controlla parecchio comportamento. Quelli comuni in pandas:
int64: interi a 64 bit con segno. Default quando tutti i valori sono numeri interi e non ci sono null.float64: virgola mobile a 64 bit. Default quando ci sono decimali, o quando una colonna int ha NaN.object: una via di fuga “questo è un oggetto Python” di NumPy. In pratica, quasi sempre significa stringhe, a volte tipi misti. Lento.string[python]/string[pyarrow]: dtype di stringa propri. Quello con backend Arrow è significativamente più veloce ed è il default moderno.bool: booleani.category: per valori che si ripetono da un piccolo insieme fisso (pensa: codici paese, enum di stato). Memorizzati come interi sotto il cofano con una tabella di lookup; risparmia enorme memoria su dataset reali.datetime64[ns]: timestamp con precisione al nanosecondo.timedelta64[ns]: durate.
Poi gli equivalenti nullable, i tipi extension di pandas, lettera maiuscola davanti: Int64, Float64, boolean, string. Supportano pd.NA (un valore “mancante” proprio) senza cambiare il dtype. Sono stati il ponte tra il vecchio pandas con backend NumPy e il nuovo pandas con backend Arrow; li vedi ancora ma per codice nuovo, vai dritto al backend Arrow.
# Vecchio stile: una colonna int con un valore mancante diventa float
pd.Series([1, 2, None])
# 0 1.0
# 1 2.0
# 2 NaN
# dtype: float64
# Backend Arrow: rimane intera, ottiene un NA proprio
pd.Series([1, 2, None], dtype="int64[pyarrow]")
# 0 1
# 1 2
# 2 <NA>
# dtype: int64[pyarrow]
Quella seconda è quella che vuoi davvero. Le stringhe sono la vittoria più grossa:
# Vecchio stile: array NumPy "object" di istanze str di Python
pd.Series(["alpha", "beta", "gamma"]) # dtype: object
# Stringhe Arrow: memoria più stretta, operazioni su stringhe ~10x più veloci
pd.Series(["alpha", "beta", "gamma"], dtype="string[pyarrow]")
Se metti pd.options.future.infer_string = True vicino all’inizio del tuo script, pandas userà automaticamente il dtype stringa di Arrow quando legge colonne di testo. Fallo. Il te futuro avrà meno preoccupazioni.
DataFrame: un dict di Series allineate
Un DataFrame è un dizionario di Series che condividono tutte lo stesso indice di righe. Questo è l’intero modello mentale. Ogni colonna è una Series, ogni colonna ha la stessa lunghezza, e ogni colonna ha le stesse etichette di riga.
import pandas as pd
df = pd.DataFrame({
"country": ["IT", "FR", "DE", "ES"],
"population_m": [59, 68, 84, 47],
"in_eurozone": [True, True, True, True],
})
print(df)
country population_m in_eurozone
0 IT 59 True
1 FR 68 True
2 DE 84 True
3 ES 47 True
L’hai creato da un dict-of-list. Le chiavi sono diventate i nomi di colonna, i valori sono diventati Series, l’indice di riga è stato di default un RangeIndex da 0.
Puoi anche costruire un DataFrame da una lista di dict (un dict per riga), che è più vicino a quello che ti torna indietro da un’API JSON:
rows = [
{"country": "IT", "population_m": 59, "in_eurozone": True},
{"country": "FR", "population_m": 68, "in_eurozone": True},
{"country": "DE", "population_m": 84, "in_eurozone": True},
]
df = pd.DataFrame(rows)
O da un array NumPy 2D (poi specifichi le colonne tu):
import numpy as np
arr = np.array([[1, 2, 3], [4, 5, 6]])
df = pd.DataFrame(arr, columns=["a", "b", "c"])
O, cosa più comune nella vita reale, da un file:
df = pd.read_csv("countries.csv")
df = pd.read_parquet("countries.parquet")
Lo facciamo nella lezione 27.
Gli attributi che toccherai ogni giorno
Un DataFrame ha un piccolo set di attributi a cui ricorrerai costantemente:
df.shape # (4, 3) -- righe, colonne
df.dtypes # il dtype di ogni colonna, come una Series
df.index # le etichette di riga (qui un RangeIndex(0, 4))
df.columns # le etichette di colonna (Index(['country', 'population_m', 'in_eurozone']))
df.values # l'array NumPy sottostante (evita di usarlo; usa .to_numpy() se ti serve)
df.info() # sommario stampato: dtype, conteggi non-null, uso di memoria
df.head(3) # prime 3 righe
df.tail(3) # ultime 3 righe
df.describe() # statistiche di sintesi per colonne numeriche
df.info() e df.head() sono le due cose che esegui su qualsiasi nuovo DataFrame, ogni volta. Sono come controlli quello che ti è effettivamente arrivato, prima di qualsiasi computazione.
Selezionare una colonna ti dà una Series. Due colonne ti danno un DataFrame.
Questo inciampa i principianti ed è critico interiorizzarlo:
df["country"] # Series -- 1-D
df[["country"]] # DataFrame -- 2-D, con una colonna
df[["country", "population_m"]] # DataFrame, due colonne
Le parentesi singole con una stringa restituiscono una Series. Le parentesi doppie con una lista di stringhe, anche una lista di una sola, restituiscono un DataFrame. Molte funzioni accettano una cosa o l’altra ma non entrambe, e “gli ho passato df["col"] e si è lamentato che voleva una cosa 2-D” è un’occorrenza quotidiana all’inizio.
Lo specchio succede con le righe: df.loc[3] restituisce una Series (una riga), df.loc[[3]] restituisce un DataFrame (una tabella di 1 riga). Copriremo la selezione di righe come si deve nella lezione 28.
L’Index: noioso, importante
Ogni DataFrame ha un Index per le sue righe, e i nomi delle colonne sono essi stessi un Index per le colonne. L’indice di riga di default è un RangeIndex (0, 1, 2, …), ma puoi usare qualsiasi cosa: date, codici paese, ID utente, combinazioni multi-livello di tutti e tre.
df = pd.DataFrame(
{"population_m": [59, 68, 84, 47]},
index=["IT", "FR", "DE", "ES"],
)
df.loc["IT"] # seleziona la riga IT per etichetta
Un MultiIndex è un indice con più livelli, ad esempio (paese, anno), (regione, negozio, data). È come fai dati gerarchici o stile pivot-table in pandas.
df = pd.DataFrame(
{"sales": [100, 110, 95, 105]},
index=pd.MultiIndex.from_tuples(
[("IT", 2024), ("IT", 2025), ("FR", 2024), ("FR", 2025)],
names=["country", "year"],
),
)
df.loc["IT"] # tutte le righe per IT
df.loc[("IT", 2025)] # specifico (paese, anno)
I MultiIndex sono potenti e un po’ fastidiosi. Li reincontreremo nella lezione 32 quando rimodelliamo i dati.
L’opinione nel 2026: non essere precioso sull’indice. Molti tutorial pandas moderni e gli utenti di Polars sostengono che dovresti per lo più lasciare l’indice come RangeIndex di default e trattare le tue colonne “chiave” come colonne normali. È un modello mentale più pulito. Usa un indice significativo quando ne benefici davvero (time series, join per etichetta, aggregazioni gerarchiche); saltalo altrimenti.
Backend NumPy contro backend Arrow, con un esempio
Per rendere concreta la distinzione di backend:
import pandas as pd
# Backend NumPy (il default storico)
df_np = pd.DataFrame({
"name": ["Ada", "Linus", "Grace"],
"score": [98, None, 92],
})
print(df_np.dtypes)
# name object
# score float64 <-- promosso a float per via del None
# dtype: object
# Backend Arrow
df_pa = pd.DataFrame({
"name": ["Ada", "Linus", "Grace"],
"score": [98, None, 92],
}).convert_dtypes(dtype_backend="pyarrow")
print(df_pa.dtypes)
# name string[pyarrow]
# score int64[pyarrow] <-- rimane intero, con NA proprio
# dtype: object
Entrambi i DataFrame si comportano uguale per quasi ogni operazione che farai; quello con backend Arrow ha dtype più puliti, stringhe più veloci, e una rappresentazione più onesta dei dati mancanti. Andando avanti, ogni esempio in questo corso che non dimostra specificamente un comportamento del backend NumPy assumerà che tu stia lavorando con backend Arrow. Imposta dtype_backend="pyarrow" sulle tue chiamate read_* e ci sei.
Cosa viene dopo
Ora che sai cosa stai tenendo in mano quando tieni un DataFrame, la lezione 27 copre come ottenerne effettivamente uno: pd.read_csv, pd.read_parquet, le insidie in ogni formato, e perché Parquet è la risposta giusta quasi ogni volta che è permesso qualcosa di meglio del CSV.
Letture di approfondimento
- pandas: Intro to data structures: l’introduzione ufficiale, vale la pena leggerla una volta.
- pandas dtypes: il riferimento canonico.
- PyArrow-backed dtypes: il default moderno spiegato come si deve.
Ci vediamo martedì per il vasto mondo di pd.read_*.