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.
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
Da leggere
Articoli correlati
Il centesimo che non torna: fare i conti in euro con Python
Un totale che sbaglia di un centesimo non è un errore di battitura: è il modo in cui il calcolatore rappresenta i numeri con la virgola. Come si scrive in Python un calcolo di fattura che torna, e perché due metodi entrambi corretti possono dare due totali diversi.
Leggi l'articolo →Riscrivere un file senza il rischio di trovarlo vuoto
La modalità di scrittura «w» azzera il file nell’istante in cui lo apre: fra quel momento e l’ultimo svuotamento del buffer il contenuto vecchio è già perso e quello nuovo non c’è ancora. La ricetta per chiudere quella finestra è scrivere accanto e poi spostare, con os.replace e due fsync. Codice completo, eseguito, e la prova fatta uccidendo il processo a metà.
Leggi l'articolo →Il 25 ottobre le due e mezza arriva due volte
Fra quindici giorni l’ora legale finisce e per sessanta minuti ogni marcatore temporale locale vale due istanti. Uno script Python di sola libreria standard riconosce le ore ambigue e quelle inesistenti, le converte in UTC e dichiara quelle che non riesce a sciogliere.
Leggi l'articolo →