Python, dalle fondamenta Lezione 27 / 60

Leggere i dati: CSV, Parquet, Excel, JSON, SQL

La famiglia pd.read_*, le insidie di ogni formato, e perche' nel 2026 Parquet e' il formato di riferimento.

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. Usa zstd per una compressione migliore a costo CPU leggermente più alto; usa gzip solo 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

Ci vediamo venerdì.

Cerca