Un DataFrame pandas è utile solo se ci riesci a far entrare i dati. Oggi vediamo la famiglia pd.read_*, le dozzine abbondanti di funzioni che trasformano un file esterno o una query su database in un DataFrame, le opzioni di ognuna che mordono le persone in produzione, e i corrispondenti writer df.to_*. Alla fine saprai quale formato leggere, quali argomenti impostare esplicitamente anche quando “il default funziona”, e come affrontare il fatto che i dati del mondo reale sono pieni di casi limite sporchi che la documentazione omette con grazia.
La forma generale dell’API è pd.read_<format>(path_or_buffer, **options). Quasi ogni reader accetta un percorso locale, una URL (HTTP, HTTPS, S3, GCS, Azure se è installato pyarrow), o un oggetto file-like. Quasi tutti accettano ormai dtype_backend="pyarrow". Quasi tutti accettano chunksize= per fare streaming di file grossi. Le differenze stanno nelle opzioni specifiche del formato.
CSV, il cavallo da tiro pieno di trappole
pd.read_csv è la funzione che chiamerai più di ogni altra in questo corso, perché il CSV è quello che le altre persone ti mandano. È anche quella con più armi puntate ai piedi.
import pandas as pd
df = pd.read_csv("sales.csv")
Questo funziona per un file pulito. Raramente lo è. Gli argomenti che contano in produzione:
dtype, spegni l’inferenza dei tipi per le colonne note. Di default pandas legge ogni colonna, la scansiona tutta e indovina il dtype. Va bene per analisi una tantum, malissimo per pipeline di produzione: un singolo refuso alla riga 50.000 trasforma una colonna di interi in object, rompe le join a valle, e il bug salta fuori solo in produzione. In una pipeline, dichiara i dtype:
df = pd.read_csv(
"sales.csv",
dtype={
"order_id": "int64",
"customer_id": "string",
"amount": "float64",
"currency": "category",
},
)
Tutto quello che non elenchi viene comunque inferito. Questo schema, esplicito sulle colonne che ti interessano, pigro sul resto, è il compromesso che funziona.
encoding, UTF-8 è lo standard ma i dati europei sono spesso in Latin-1. Un CSV esportato dal vecchio Excel su una macchina tedesca o italiana è frequentemente cp1252 (Windows Latin-1) e contiene €, à, ñ, ß resi in singoli byte. UTF-8 si strozza. Se vedi UnicodeDecodeError, prova:
df = pd.read_csv("export.csv", encoding="cp1252")
# oppure
df = pd.read_csv("export.csv", encoding="latin-1")
Se non conosci l’encoding e il file è piccolo, la libreria chardet può tirare a indovinare; per file grossi, aprilo in un editor esadecimale e cerca il BOM o i byte non ASCII.
sep, la virgola è il default ma i CSV europei usano il punto e virgola. Siccome la virgola è il separatore decimale in molti locale europei, l’esportatore CSV spesso sceglie ; come separatore di campo. Se il tuo “CSV” torna come una sola colonna gigante con dei punto e virgola dentro, ecco perché:
df = pd.read_csv("italian_sales.csv", sep=";", decimal=",")
(Nota decimal=",", sì, l’argomento per il separatore decimale esiste, e sì, ti servirà.)
parse_dates, sempre esplicito. Pandas non fa il parsing automatico delle colonne data. Se lo ignori, ottieni stringhe di dtype object che sembrano date ma non si ordinano, non si filtrano e non si fa aritmetica correttamente:
df = pd.read_csv(
"events.csv",
parse_dates=["created_at", "updated_at"],
date_format="ISO8601", # pandas 2.0+; piu' veloce e piu' rigoroso del vecchio auto-parser
)
date_format="ISO8601" funziona per qualsiasi stringa ISO-8601; per formati non ISO passa il pattern strptime esplicito (per esempio date_format="%d/%m/%Y").
chunksize, per i file che non stanno in memoria. Se hai un CSV da 50 GB e 16 GB di RAM, non lo puoi leggere intero. chunksize restituisce un iteratore di DataFrame:
total = 0
for chunk in pd.read_csv("massive.csv", chunksize=500_000):
total += chunk["amount"].sum()
500.000 righe è una dimensione di chunk ragionevole; tarala in base alla larghezza delle righe. Vediamo un esempio completo di streaming alla fine.
dtype_backend="pyarrow", per il codice nuovo accendilo e basta. Come visto nella lezione 26, il backend Arrow ti dà tipi nullable veri e stringhe più veloci. Nel 2026 non c’è alcun lato negativo:
df = pd.read_csv("sales.csv", dtype_backend="pyarrow")
Parquet, quello che dovresti usare quando puoi
CSV è il formato che gli altri ti mandano. Parquet è il formato che dovresti usare ogni volta che la scelta è tua.
Parquet è un formato binario colonnare nato dal mondo Hadoop (originariamente), ora lo standard per lo scambio di dati analitici. È colonnare (quindi leggere tre colonne da un file di mille colonne è veloce), salva schema e dtype (quindi niente encoding o inferenza dei tipi da indovinare), è compresso di default (in genere 5-10 volte più piccolo del CSV equivalente), ed è la lingua franca di ogni tool dati moderno: DuckDB, Polars, Spark, Snowflake, BigQuery, Athena, ogni data lake cloud.
df = pd.read_parquet("sales.parquet", engine="pyarrow")
df.to_parquet("output.parquet", engine="pyarrow", compression="snappy")
Un paio di cose da sapere:
engine="pyarrow". L’altra opzione èfastparquet. Usa pyarrow. È più veloce, più compatibile, meglio mantenuto, ed è quello che usano anche tutti gli altri tool.- Opzioni di compressione,
snappyè il default, veloce e leggero. Usazstdper una compressione migliore a costo CPU leggermente più alto; usagzipsolo se qualche consumatore a valle lo richiede. - Leggere un sottoinsieme di colonne è gratis, grazie al layout colonnare:
pd.read_parquet("file.parquet", columns=["a", "b"])legge da disco solo quelle colonne. - Lo schema viene preservato, quindi non devi rispecificare i dtype in lettura. Già solo questo vale il cambio di formato.
Se la tua squadra si scambia CSV in una pipeline interna, il singolo cambiamento ingegneristico a maggior impatto che puoi fare è “passa a Parquet”. Lo spazio su disco scende di 5-10 volte, la velocità di lettura sale di 5-20 volte, i bug sui dtype spariscono. Non c’è circa nessun motivo per non farlo.
Excel, lento, a volte necessario
Gli umani modificano i file Excel. Te li mandano. Puoi insistere sui CSV (perderai) oppure imparare a leggere bene gli xlsx.
df = pd.read_excel(
"report.xlsx",
sheet_name="Sales",
skiprows=3, # salta il titolo e le righe vuote che gli umani mettono in cima
header=0, # quale riga (dopo lo skip) e' l'intestazione
usecols="A:F", # solo queste colonne
)
pd.read_excel richiede openpyxl per .xlsx (e xlrd per gli antichi .xls):
uv add openpyxl
È lento. Leggere un xlsx da un milione di righe ci mette un minuto. Se un processo insiste su input Excel, il workaround standard è “leggi l’xlsx una volta, salvalo come Parquet, da lì in poi lavora su Parquet”.
sheet_name=None restituisce un dict {sheet_name: DataFrame}, utile per i workbook in cui ogni foglio è una regione o un mese.
JSON, piatto e annidato
Per JSON piatto (una lista di oggetti, ognuno con le stesse chiavi), pd.read_json funziona:
df = pd.read_json("events.json")
JSON Lines, un oggetto JSON per riga, lo standard per gli stream di log/evento, vuole lines=True:
df = pd.read_json("events.jsonl", lines=True)
Per il JSON annidato, qualunque cosa in cui un campo è esso stesso un oggetto o una lista di oggetti, lo strumento giusto è pd.json_normalize. Appiattisce le chiavi annidate in nomi di colonna con il punto:
import json
import pandas as pd
with open("orders.json") as f:
raw = json.load(f)
# raw = [{"id": 1, "customer": {"name": "Ada", "country": "IT"}, "items": [...]}]
df = pd.json_normalize(
raw,
record_path="items", # esplodi questa lista in una riga per item
meta=["id", ["customer", "name"], ["customer", "country"]],
)
json_normalize è la funzione a cui ti rivolgerai ogni volta che chiami una REST API. Vale dieci minuti con la documentazione e un esempio reale per costruire la memoria muscolare.
SQL, il modo giusto
pd.read_sql accetta o una stringa con la query SQL o un nome di tabella, più una connessione. Usa SQLAlchemy per la connessione: funziona con ogni database comune ed è quello contro cui read_sql viene testato:
from sqlalchemy import create_engine
import pandas as pd
engine = create_engine("postgresql+psycopg2://user:pass@host:5432/db")
df = pd.read_sql(
"SELECT order_id, amount, created_at FROM orders WHERE created_at >= %(since)s",
engine,
params={"since": "2025-01-01"},
parse_dates=["created_at"],
)
Parametrizza sempre le query (l’argomento params=). Concatenare input utente in una stringa SQL è una SQL injection in agguato e qui non si fa.
Per result set molto grandi, chunksize= funziona allo stesso modo di read_csv:
for chunk in pd.read_sql("SELECT * FROM events", engine, chunksize=100_000):
process(chunk)
Una nota per il 2026: parecchio lavoro analitico si è spostato su DuckDB, che può interrogare file Parquet e DataFrame pandas direttamente in SQL. Se fai analitica in-process, duckdb.query("SELECT ... FROM df").to_df() è spesso la risposta più pulita rispetto a read_sql contro un database remoto. Lo vedremo di sfuggita più avanti nel corso.
Cloud storage
Se è installato pyarrow, ogni reader accetta nativamente le URL cloud:
df = pd.read_parquet("s3://my-bucket/sales/2025/03/") # intera partizione
df = pd.read_csv("gs://my-bucket/users.csv")
df = pd.read_parquet("az://my-container/events.parquet")
Ti serviranno le credenziali configurate nei posti soliti (~/.aws/credentials, GOOGLE_APPLICATION_CREDENTIALS, eccetera), nello stesso modo in cui se le aspetta l’SDK sottostante.
Scrittura, la famiglia to_*
Ogni reader ha un writer corrispondente:
df.to_csv("out.csv", index=False) # passa sempre index=False a meno che tu non voglia davvero l'index nel file
df.to_parquet("out.parquet", engine="pyarrow", compression="snappy")
df.to_excel("out.xlsx", sheet_name="Results", index=False)
df.to_json("out.jsonl", orient="records", lines=True)
df.to_sql("orders", engine, if_exists="append", index=False)
L’errore più comune in scrittura è lasciare index=True (il default), che riversa un RangeIndex pandas nel file come una fantomatica prima colonna senza nome. Passa index=False a meno che il tuo index non porti informazione.
Un esempio reale: streaming di un CSV da 5 GB in Parquet
Mettiamo insieme i pezzi: uno script che legge un CSV troppo grosso a chunk, deduplica per order_id e scrive un singolo file Parquet.
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
reader = pd.read_csv(
"big_orders.csv",
chunksize=500_000,
dtype={
"order_id": "int64",
"customer_id": "string",
"amount": "float64",
"currency": "category",
},
parse_dates=["created_at"],
date_format="ISO8601",
dtype_backend="pyarrow",
)
seen_ids: set[int] = set()
writer: pq.ParquetWriter | None = None
try:
for chunk in reader:
# rimuovi i duplicati interni al chunk
chunk = chunk.drop_duplicates("order_id")
# rimuovi i duplicati cross-chunk
chunk = chunk[~chunk["order_id"].isin(seen_ids)]
seen_ids.update(chunk["order_id"].tolist())
table = pa.Table.from_pandas(chunk, preserve_index=False)
if writer is None:
writer = pq.ParquetWriter("orders.parquet", table.schema, compression="snappy")
writer.write_table(table)
finally:
if writer is not None:
writer.close()
Questo fa streaming del CSV, non tiene mai più di 500.000 righe in memoria contemporaneamente, e produce un file Parquet pulito alla fine. Il set seen_ids è l’unica cosa che cresce con i dati, e 5 milioni di interi a 64 bit sono 40 MB, gestibili su qualsiasi macchina. Per file davvero enormi passeresti a un Bloom filter; per un “CSV da 5 GB scomodo da gestire”, questo basta.
Cosa c’è dopo
Ora sai ottenere un DataFrame. La lezione 28 è selezione e filtraggio: lo zoo loc / iloc / boolean mask, la parte di pandas in cui le scorciatoie sintattiche si fanno concorrenza tra loro e tanto codice dall’aria furba viene scritto per il motivo sbagliato. Troveremo i pattern che vale la pena tenersi.
Letture di approfondimento
- pandas: IO tools, ogni reader e writer, con la lista completa delle opzioni.
- Documentazione Apache Parquet, il formato, in breve.
- pyarrow.fs, il livello di filesystem cloud che pandas usa sotto il cofano.
Ci vediamo venerdì.