Blog Programmazione

Il CSV che arriva dal cliente: leggerlo senza tirare a indovinare

Codifica, separatore, virgola decimale e date: quattro ambiguità che si sciolgono guardando l'intera colonna, non la singola cella. Script Python di sola libreria standard, eseguito e misurato.

Arriva un file CSV: lo manda il commercialista, lo esporta il gestionale di un cliente, lo scarica un portale. Lo apri con il foglio di calcolo e sembra a posto, tranne che gli importi hanno perso i decimali, le date di aprile sono finite a gennaio e i cognomi accentati mostrano una «Ã» al posto della «à». Il problema non è il tuo programma: CSV non è un formato, è una famiglia di abitudini.

Lo dice la fonte stessa. L'RFC 4180 è del 2005, ha stato «Informational» — non è uno standard — e ammette: «Surprisingly, while this format is very common, it has never been formally documented»; «there is no formal specification in existence, which allows for a wide variety of interpretations of CSV files». Quel poco che fissa — la virgola come separatore, CR+LF a fine riga, US-ASCII come «common usage» — è esattamente quello che un'esportazione italiana disattende tre volte su tre.

Quattro ambiguità, una regola

Le ambiguità sono quattro: la codifica dei caratteri, il separatore dei campi, il separatore decimale e il formato delle date. Si affrontano tutte con la stessa regola, che è il punto di questo articolo: non si decide guardando la singola cella, si decide guardando l'intera colonna. E quando nemmeno la colonna basta, non si indovina: si dichiara l'ambiguità e si lascia il valore come stava.

Lo script qui sotto fa questo con la sola libreria standard: legge il file, dichiara ciò che ha dedotto e scrive una copia normalizzata — UTF-8, virgola, punto decimale, date ISO 8601. È stato eseguito nel container di questa redazione con Python 3.13.16: gli esiti qui sotto sono output reali.

La codifica non si rileva: si esclude

Non esiste modo di sapere con certezza come è codificato un file di testo: i byte non lo dicono. Si può solo escludere. UTF-8 si auto-valida — moltissime sequenze di byte non sono UTF-8 valido — quindi un file che si decodifica interamente come UTF-8 quasi certamente lo è. Windows-1252 (cp1252) rifiuta cinque byte che non ha mappati. Latin-1 non fallisce mai: «The simplest text encoding (called 'latin-1' or 'iso-8859-1') maps the code points 0–255 to the bytes 0x0–0xff», dice la documentazione di codecs. Ogni byte ha un carattere, quindi qualunque file «si legge»: male, ma si legge.

Provato nel container su una riga che contiene il byte 0x81, che cp1252 non mappa:

utf-8    -> UnicodeDecodeError: invalid start byte al byte 23
cp1252   -> UnicodeDecodeError: character maps to <undefined> al byte 23
latin-1  -> 'ssi;prezzo \x81 speciale\n'

Da qui l'ordine dei candidati nello script: prima UTF-8, poi cp1252, latin-1 solo come ultima spiaggia. Secondo limite: un file di soli caratteri ASCII è valido in tutte e tre. Quello da 200.000 righe usato più sotto è scritto in cp1252, non ha accenti e lo script lo dichiara «utf-8»: non è un errore, le due letture danno lo stesso testo.

Il BOM, e l'intestazione che non si trova

C'è un caso in cui i byte invece parlano. «Microsoft invented a variant of UTF-8 (that Python calls "utf-8-sig") for its Notepad program», racconta la documentazione: «Before any of the Unicode characters is written to the file, a UTF-8 encoded BOM (which looks like this as a byte sequence: 0xef, 0xbb, 0xbf) is written», e in lettura «utf-8-sig will skip those three bytes if they appear as the first three bytes in the file».

Se il file ha il BOM e lo leggi come utf-8 semplice, il BOM diventa un carattere invisibile incollato alla prima intestazione. È la causa di una classe intera di errori «la colonna non esiste»:

con utf-8     -> ['\ufeffdata', 'importo']
con utf-8-sig -> ['data', 'importo']

La tentazione è usare sempre utf-8-sig e chiuderla lì. Come rilevamento non va: decodifica senza protestare anche i file che il BOM non l'hanno, quindi farebbe risultare «con firma» ogni file UTF-8. Lo script guarda prima i byte, con codecs.BOM_UTF8. In uscita scrive UTF-8 senza BOM, perché la stessa pagina avverte che «In UTF-8, the use of the BOM is discouraged and should generally be avoided»; se il file pulito deve tornare in Excel, basta cambiare quel parametro in encoding="utf-8-sig".

Il separatore: Sniffer serve, ma va sorvegliato

La libreria standard ha una classe apposita: «The Sniffer class is used to deduce the format of a CSV file». Il metodo sniff() «analyze the given sample and return a Dialect subclass reflecting the parameters found» e accetta i candidati: «If the optional delimiters parameter is given, it is interpreted as a string containing possible valid delimiter characters». Passarglielo è la prima precauzione: senza, spazio e due punti sono separatori ammessi.

La seconda viene da una frase da leggere due volte: «If several delimiters fit the sample equally well — for example if both ',' and ';' split every row consistently — the delimiters listed in the preferred attribute are preferred, in that order, no matter how many times each of them occurs». L'attributo preferred, letto nel container, vale [',', '\t', ';', ' ', ':']: la virgola vince sul punto e virgola. Ed esiste un caso italianissimo in cui pareggiano — punto e virgola, importi con la virgola decimale, nessuna riga di intestazione.

campione:
Alfa;1,50
Beta;2,30
Gamma;3,10

separatore dedotto: ','
  con ',': [['Alfa;1', '50'], ['Beta;2', '30'], ['Gamma;3', '10']]
  con ';': [['Alfa', '1,50'], ['Beta', '2,30'], ['Gamma', '3,10']]

Due colonne pulite in entrambi i casi, e la scelta sbagliata. Il quarto esito possibile è invece il fallimento dichiarato: su un file a colonna singola sniff() solleva csv.Error: 'Could not determine delimiter', messaggio verificato sia eseguendo sia leggendo il sorgente del modulo installato. Un'eccezione è una buona notizia; è il silenzio della trappola precedente che fa danni. Le conseguenze pratiche: limitare i candidati, stampare sempre il separatore dedotto e prevedere un modo per imporlo a mano — nello script è il terzo argomento, opzionale.

I decimali: la regola delle terne

Qui l'ambiguità è irriducibile e va riconosciuta come tale. La stringa 1.234 vale milleduecentotrentaquattro in convenzione italiana e uno-virgola-duecentotrentaquattro in convenzione inglese. Nessuna euristica può deciderlo: sono due numeri diversi, entrambi plausibili.

Si può però usare la colonna intera, con una regola di buon senso formalizzata: il separatore delle migliaia è ammesso solo nella parte intera e solo in gruppi di esattamente tre cifre, il decimale compare al massimo una volta. Così su 1.234,56 la lettura «punto decimale» cade, perché metterebbe una virgola fra i decimali; su 89,90 cade perché 90 non è un gruppo di tre. La colonna è determinata. La stessa regola su 1.234 e 2.500 dichiara invece l'ambiguità: lo script esce con codice 1, non converte e lo scrive nella relazione.

Le date: decide la colonna, non la cella

Stessa logica. 03/04/2026 è il 3 aprile o il 4 marzo: la cella non lo sa. Ma se nella stessa colonna c'è un 17/04/2026, il formato americano è escluso e tutta la colonna è decisa, cella ambigua compresa. Lo script prova cinque formati sulla colonna e tiene quelli che leggono tutti i valori; se ne restano più di uno con gli stessi risultati li considera indifferenti, altrimenti dichiara l'ambiguità. Una colonna con formati mescolati non passa nessun candidato e finisce trattata come testo: è un limite, non una virtù.

Un'ultima scelta, con la sua fonte: il modulo offre anche has_header(), ma la documentazione avverte che «This method is a rough heuristic and may produce both false positives and negatives». Lo script non lo usa: assume che la prima riga sia l'intestazione, e lo dichiara. Vale più di un'euristica in più.

Lo script

Centosessanta righe, nessuna dipendenza esterna: csv, codecs, decimal, datetime, pathlib. Il file di uscita è aperto con newline="" perché così prescrive la documentazione: «If newline='' is not specified, newlines embedded inside quoted fields will not be interpreted correctly, and on platforms that use \r\n line endings on write an extra \r will be added».

#!/usr/bin/env python3
"""Legge un CSV di provenienza incerta: rileva codifica e separatore,
deduce colonna per colonna la convenzione dei numeri e delle date e
scrive una copia normalizzata (UTF-8, separatore virgola, punto
decimale, date ISO 8601). Dove la convenzione resta ambigua non
normalizza e lo dichiara. Solo libreria standard, Python 3.9 o
successivo. Provato con Python 3.13.16 su Linux.
Uso: csvdiagnosi.py sorgente.csv destinazione.csv [separatore]
Uscita: 0 tutto determinato, 1 colonne ambigue, 2 errore."""

import codecs
import csv
import sys
from datetime import datetime
from decimal import Decimal, InvalidOperation
from pathlib import Path

CODIFICHE = ("utf-8", "cp1252", "latin-1")
FORMATI_DATA = ("%d/%m/%Y", "%m/%d/%Y", "%Y-%m-%d", "%d.%m.%Y", "%d-%m-%Y")
SEPARATORI = ";,\t|"


def decodifica(grezzo):
    """(nome della codifica, testo). Il BOM si riconosce dai byte, non
    si tira a indovinare: utf-8-sig decodifica anche i file senza BOM."""
    if grezzo.startswith(codecs.BOM_UTF8):
        return "utf-8-sig", grezzo.decode("utf-8-sig")
    for nome in CODIFICHE:
        try:
            return nome, grezzo.decode(nome)
        except UnicodeDecodeError:
            continue
    raise RuntimeError("nessuna codifica candidata ha funzionato")


def separatore(testo):
    campione = testo[:8192]
    try:
        return csv.Sniffer().sniff(campione, delimiters=SEPARATORI).delimiter, "dedotto"
    except csv.Error:
        conteggi = {s: campione.count(s) for s in SEPARATORI}
        scelto = max(conteggi, key=conteggi.get)
        return (scelto, "per conteggio") if conteggi[scelto] else (";", "predefinito")


def come_numero(valore, decimale):
    """Decimal, oppure None se il valore non e' leggibile con questa
    convenzione. Il separatore delle migliaia e' ammesso solo nella
    parte intera e solo in gruppi di tre cifre."""
    migliaia = "." if decimale == "," else ","
    testo = valore.replace(" ", "").lstrip("+")
    intera, trovato, decimali = testo.partition(decimale)
    if decimale in decimali or migliaia in decimali:
        return None
    gruppi = intera.lstrip("-").split(migliaia)
    if len(gruppi) > 1 and not (
        1 <= len(gruppi[0]) <= 3 and all(len(g) == 3 for g in gruppi[1:])
    ):
        return None
    try:
        return Decimal(intera.replace(migliaia, "") + ("." + decimali if trovato else ""))
    except InvalidOperation:
        return None


def come_data(valore, formato):
    try:
        return datetime.strptime(valore, formato).date()
    except ValueError:
        return None


def convenzione(valori, candidati, lettura):
    """(candidato, esito). Esito: 'determinata' se un solo candidato
    legge tutti i valori, 'indifferente' se piu' candidati danno lo
    stesso risultato su tutti, 'ambigua' se ne danno di diversi,
    'nessuna' se nessun candidato legge tutti i valori."""
    validi = [c for c in candidati if all(lettura(v, c) is not None for v in valori)]
    if not validi:
        return None, "nessuna"
    if len(validi) == 1:
        return validi[0], "determinata"
    letture = {tuple(lettura(v, c) for v in valori) for c in validi}
    return validi[0], "indifferente" if len(letture) == 1 else "ambigua"


def analizza(dati, intestazione):
    tipi = {}
    for indice in range(len(intestazione)):
        valori = [r[indice].strip() for r in dati if indice < len(r) and r[indice].strip()]
        if not valori:
            tipi[indice] = ("testo", None, "colonna vuota")
            continue
        chiave, esito = convenzione(valori, (",", "."), come_numero)
        if esito != "nessuna":
            tipi[indice] = ("numero", chiave, esito)
            continue
        chiave, esito = convenzione(valori, FORMATI_DATA, come_data)
        tipi[indice] = ("data", chiave, esito) if esito != "nessuna" else ("testo", None, "")
    return tipi


def normalizza(cella, tipo, chiave, esito):
    valore = cella.strip()
    if not valore or esito == "ambigua" or tipo == "testo":
        return valore
    if tipo == "numero":
        return str(come_numero(valore, chiave))
    return come_data(valore, chiave).isoformat()


def main(argomenti):
    if not 2 <= len(argomenti) <= 3:
        print(__doc__.splitlines()[-2], file=sys.stderr)
        return 2
    sorgente, destinazione = Path(argomenti[0]), Path(argomenti[1])
    imposto = argomenti[2] if len(argomenti) == 3 else None
    try:
        grezzo = sorgente.read_bytes()
        codifica, testo = decodifica(grezzo)
    except (OSError, RuntimeError) as errore:
        print(f"errore: {errore}", file=sys.stderr)
        return 2
    sep, modo = (imposto, "imposto") if imposto else separatore(testo)
    righe = list(csv.reader(testo.splitlines(), delimiter=sep))
    if len(righe) < 2:
        print("errore: meno di due righe, niente da analizzare", file=sys.stderr)
        return 2
    intestazione, dati = righe[0], righe[1:]
    tipi = analizza(dati, intestazione)
    irregolari = sum(1 for r in dati if len(r) != len(intestazione))

    print(f"file          {sorgente}  ({len(grezzo)} byte)")
    print(f"codifica      {codifica}")
    print(f"separatore    {sep!r}  ({modo})")
    print(f"righe         {len(dati)} oltre l'intestazione, {irregolari} con campi in numero diverso")
    ambigue = 0
    for indice, nome in enumerate(intestazione):
        tipo, chiave, esito = tipi[indice]
        descrizione = tipo + (f" {chiave!r}" if chiave else "")
        print(f"  colonna {indice}  {nome!r:14} {descrizione:22} {esito}")
        ambigue += esito == "ambigua"

    with destinazione.open("w", newline="", encoding="utf-8") as uscita:
        scrittore = csv.writer(uscita, delimiter=",", quoting=csv.QUOTE_MINIMAL)
        scrittore.writerow(intestazione)
        for riga in dati:
            scrittore.writerow(
                [normalizza(cella, *tipi.get(indice, ("testo", None, "")))
                 for indice, cella in enumerate(riga)]
            )
    print(f"scritto       {destinazione}")
    if ambigue:
        print(f"ATTENZIONE    {ambigue} colonna(e) ambigua(e): valori lasciati come nel file di partenza")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Eseguito su un'esportazione in cp1252 con punto e virgola, importi italiani e date giorno/mese, la relazione è questa:

file          clienti-it.csv  (140 byte)
codifica      cp1252
separatore    ';'  (dedotto)
righe         3 oltre l'intestazione, 0 con campi in numero diverso
  colonna 0  'Data'         data '%d/%m/%Y'        determinata
  colonna 1  'Cliente'      testo
  colonna 2  'Importo'      numero ','             determinata
  colonna 3  'Note'         testo
scritto       pulito-clienti-it.csv

Data,Cliente,Importo,Note
2026-04-03,Società Alfa,1234.56,saldo
2026-04-17,Caffè Beta,89.90,acconto
2026-05-02,Gamma S.r.l.,12000.00,

Quanto costa e dove non arriva

Misurato qui, con la cache del sistema svuotata prima della prova: un file di 200.000 righe e 9,1 MB viene analizzato e riscritto in 3,57 secondi, con un picco di memoria di 119 MiB. Il rapporto è il dato interessante — tredici volte la dimensione del file, perché lo script tiene in memoria il testo e tutte le righe. Per i CSV di tutti i giorni va benissimo; per un file da due gigabyte no: lì si deducono le convenzioni su un campione e si converte a flusso.

Altri limiti, dichiarati perché si vedano: lo spazio come separatore delle migliaia (1 234,56) non è gestito; i simboli di valuta attaccati al numero fanno cadere la colonna su «testo»; l'originale non viene mai toccato, la destinazione viene sovrascritta senza chiedere. Il codice d'uscita 1 va trattato come un errore da guardare, non come un avviso.

Prima di usarlo sul serio

Non far mai scrivere lo script sopra il file di partenza: la copia normalizzata è un file nuovo, con un nome nuovo. Se il CSV contiene dati personali, quella copia è un duplicato da governare come l'originale: stesso posto, stessi permessi, cancellata quando non serve. E se il file arriva da un mittente che non conosci, il problema prima della virgola decimale è un altro: aprilo in un ambiente separato.

Cosa puoi fare

Smetti di aprire i CSV di terzi nel foglio di calcolo per «dare un'occhiata»: l'apertura è già una conversione, e silenziosa. Fai passare ogni file da uno strumento che ti dica che cosa ha capito, e conserva quella relazione insieme al file importato: quando fra sei mesi un importo non torna, è l'unico documento che spiega come era stato letto. Tratta l'ambiguità dichiarata come una decisione da far prendere a una persona. E quando puoi scegliere, chiedi al fornitore il formato meno ambiguo che esiste: punto e virgola, punto decimale, date ISO 8601, UTF-8 senza BOM. Si chiede una volta e risolve per sempre.

Fonti

  1. Python 3 - csv, CSV File Reading and Writing
  2. Python 3 - codecs, Codec registry and base classes
  3. RFC 4180 - Common Format and MIME Type for Comma-Separated Values (CSV) Files