Leçon 09-4 — CSV : lire, écrire, analyser
1. Objectif
À la fin de cette leçon tu sauras : lire un fichier CSV avec csv.reader et surtout csv.DictReader, écrire un CSV propre avec csv.DictWriter (avec les deux réglages non négociables : newline="" et encoding), convertir les types (TOUT arrive en chaîne !), filtrer des lignes, agréger des données (totaux, moyennes, comptages par catégorie), et produire un CSV de synthèse à partir d'un CSV brut.
2. Pourquoi c'est important
Le CSV (Comma-Separated Values) est le format d'échange de données le plus répandu du monde : exports bancaires, relevés de courtier, extractions de bases de données, téléchargements d'à peu près tout site qui a un bouton « Exporter ». C'est du texte brut, lisible dans n'importe quel éditeur, compris par Excel, et vieux de 50 ans — il te survivra. Savoir lire un CSV, le nettoyer, en sortir des chiffres et écrire le résultat, c'est LA compétence « données » de base : celle qui transforme « j'ai un fichier de 300 lignes » en « voici les totaux par catégorie ». Et c'est le pont vers la leçon 09-5 : un rapport automatique, c'est souvent API + CSV assemblés.
3. Explication simple
Un CSV, c'est un tableau stocké en texte : une ligne par enregistrement, les colonnes séparées par des virgules, et (presque toujours) une première ligne d'en-têtes :
date,category,amount
2026-06-01,courses,54.30
2026-06-03,essence,70.00
2026-06-05,courses,23.10
Le module csv de la bibliothèque standard fait la découpe pour toi. La star, c'est DictReader : chaque ligne devient un dict dont les clés sont les en-têtes :
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
print(row["category"], row["amount"])
courses 54.30
essence 70.00
courses 23.10
Une règle domine toute la leçon : tout ce qui sort d'un CSV est une chaîne. "54.30" n'est pas un nombre tant que TU ne l'as pas converti avec float(). Oublier ça produit des bugs silencieux (voir section 6).
4. Explication approfondie
a) Pourquoi ne pas découper soi-même avec .split(",") ? Parce que le CSV réel a des pièges que le module gère et que ton split ne gérera pas : une valeur qui contient une virgule est entourée de guillemets ("Dupont, Jean",Nice), des guillemets dans la valeur sont doublés, une valeur peut contenir un retour à la ligne. csv connaît toutes ces règles. .split(",") marche jusqu'au jour où il casse — en silence.
b) newline="" : l'incantation obligatoire, expliquée. À l'ouverture d'un fichier pour du CSV — lecture ET écriture — passe toujours newline="". Pourquoi : les fins de ligne diffèrent selon les systèmes (\n Unix, \r\n Windows), et Python les traduit par défaut. Le module csv veut gérer les fins de ligne LUI-MÊME (le format l'exige) ; si Python traduit en plus, sous Windows chaque \r\n devient \r\r\n — résultat célèbre : un CSV écrit avec une ligne vide entre chaque ligne de données. newline="" dit à Python « ne touche pas aux fins de ligne ». Tu n'as pas besoin de mémoriser la mécanique : mémorise le réflexe open(..., newline="", encoding="utf-8") pour tout CSV.
c) L'encodage, ou le retour des é. Un CSV exporté d'Excel français est souvent en cp1252 ou en utf-8-sig (UTF-8 avec un marqueur invisible « BOM » au début), pas en UTF-8 pur. Symptômes : UnicodeDecodeError à la lecture, des é à la place des é, ou une première clé fantôme 'date' au lieu de 'date'. Remèdes : essaie encoding="utf-8", puis encoding="utf-8-sig" (avale le BOM s'il existe — choix par défaut raisonnable pour des fichiers d'origine inconnue), puis encoding="cp1252" pour les vieux exports Windows. Quand TOI tu écris : utf-8, toujours.
d) reader/DictReader, writer/DictWriter : qui choisir ? csv.reader renvoie des listes (row[2]) ; DictReader des dicts (row["amount"]). Le dict gagne presque toujours : le code se lit tout seul, et si l'ordre des colonnes du fichier change, ton code marche encore. Même logique en écriture : DictWriter avec fieldnames= (la liste des colonnes) + writeheader(). Réserve csv.reader aux fichiers SANS ligne d'en-têtes.
e) Le modèle d'analyse en 3 temps. Presque toute analyse CSV suit le même squelette — charge, agrège, restitue :
# 1. CHARGER : lire tout en mémoire, convertir les types UNE fois
rows = []
with open("expenses.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
row["amount"] = float(row["amount"]) # conversion à l'entrée
rows.append(row)
# 2. AGRÉGER : dict accumulateur clé → total (⏩ niveau 03)
totals = {}
for row in rows:
cat = row["category"]
totals[cat] = totals.get(cat, 0) + row["amount"]
# 3. RESTITUER : afficher, ou écrire un CSV de synthèse
Convertir à l'entrée (temps 1) est le bon endroit : après, tout ton programme manipule de vrais nombres, et les erreurs de format éclatent tôt, au chargement, pas au milieu d'un calcul.
f) Honnêteté : quand passer à pandas. Il existe une bibliothèque tierce, pandas, qui fait en 3 lignes ce que cette leçon fait en 30 : lecture typée, groupements, moyennes. ⏩ Elle est hors méthode : c'est un monde en soi (nouveaux types, nouvelle façon de penser), et l'apprendre AVANT de savoir faire à la main, c'est utiliser une calculatrice sans connaître l'addition. Le module csv te suffit largement jusqu'à ~100 000 lignes. Le jour où tu fais des croisements complexes sur de gros fichiers, apprends pandas — tu comprendras alors ce qu'il fait pour toi, et tu le déboguéras au lieu de le subir.
Difficulté honnête : rien de conceptuellement dur ici, mais deux sources de friction réelles : les fichiers des AUTRES (encodages exotiques, colonnes vides, séparateur ; à la française — voir section 6) et la discipline de conversion des types. Prévois une session de manipulation pure avant les exercices.
5. Exemples commentés
Les exemples utilisent expenses.csv :
date,category,amount
2026-06-01,courses,54.30
2026-06-03,essence,70.00
2026-06-05,courses,23.10
2026-06-08,restaurant,41.00
2026-06-11,essence,65.50
Exemple 1 — lire avec DictReader, prouver que tout est chaîne
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
rows = list(reader) # matérialiser en liste : réutilisable, mesurable
print(len(rows)) # 5
print(rows[0]) # {'date': '2026-06-01', 'category': 'courses', 'amount': '54.30'}
print(type(rows[0]["amount"])) # <class 'str'> ← LA règle de la leçon
Note : reader s'épuise à la lecture (comme un fichier) — d'où le list(reader) si tu veux repasser dessus.
Exemple 2 — filtrer et totaliser (avec conversion)
import csv
with open("expenses.csv", newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
groceries = [row for row in rows if row["category"] == "courses"]
total = sum(float(row["amount"]) for row in groceries)
print(f"{len(groceries)} dépenses courses, total {total:.2f} €")
Sortie :
2 dépenses courses, total 77.40 €
Le float(...) DANS le sum est ce qui sépare 77.40 de la catastrophe silencieuse "54.3023.10" (voir section 6-1).
Exemple 3 — agréger par catégorie : le dict accumulateur
import csv
totals = {}
counts = {}
with open("expenses.csv", newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
cat = row["category"]
totals[cat] = totals.get(cat, 0) + float(row["amount"])
counts[cat] = counts.get(cat, 0) + 1
for cat, total in sorted(totals.items(), key=lambda kv: kv[1], reverse=True):
average = total / counts[cat]
print(f"{cat:<12} {total:>8.2f} € ({counts[cat]} dépenses, moy. {average:.2f} €)")
Sortie (triée du plus dépensier au moins) :
essence 135.50 € (2 dépenses, moy. 67.75 €)
courses 77.40 € (2 dépenses, moy. 38.70 €)
restaurant 41.00 € (1 dépenses, moy. 41.00 €)
Le motif d[k] = d.get(k, 0) + valeur est LE geste d'agrégation — tu le referas des centaines de fois. (lambda : ⏩ croisé au niveau 04 ; ici il dit juste « trie sur la valeur, pas la clé ».)
Exemple 4 — écrire un CSV de synthèse avec DictWriter
import csv
summary = [
{"category": "essence", "total": 135.50, "count": 2},
{"category": "courses", "total": 77.40, "count": 2},
{"category": "restaurant", "total": 41.00, "count": 1},
]
with open("summary.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["category", "total", "count"])
writer.writeheader() # écrit la ligne category,total,count
writer.writerows(summary) # toutes les lignes d'un coup
Contenu de summary.csv :
category,total,count
essence,135.5,2
courses,77.4,2
restaurant,41.0,1
Les trois indispensables sont là : mode "w", newline="" (sinon lignes vides sous Windows), writeheader() (sinon fichier sans en-têtes, inutilisable par le prochain DictReader).
Exemple 5 — pipeline complet : CSV brut → CSV de synthèse
import csv
def load_expenses(path):
"""Load the CSV and convert types at the boundary."""
rows = []
with open(path, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
row["amount"] = float(row["amount"])
row["month"] = row["date"][:7] # '2026-06-01' → '2026-06'
rows.append(row)
return rows
def totals_by(rows, key):
"""Aggregate amounts by any column."""
totals = {}
for row in rows:
totals[row[key]] = totals.get(row[key], 0) + row["amount"]
return totals
def write_summary(totals, path):
with open(path, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=["key", "total"])
writer.writeheader()
for key, total in sorted(totals.items()):
writer.writerow({"key": key, "total": round(total, 2)})
rows = load_expenses("expenses.csv")
write_summary(totals_by(rows, "category"), "by_category.csv")
write_summary(totals_by(rows, "month"), "by_month.csv")
print(f"{len(rows)} lignes traitées, 2 synthèses écrites.")
Trois petites fonctions, chacune une responsabilité (⏩ niveau 04) : la même totals_by sert pour les catégories ET pour les mois. C'est le squelette du mini-projet.
6. Erreurs fréquentes
1) Sommer des chaînes — le bug silencieux emblématique
total = 0
for row in csv.DictReader(f):
total = total + row["amount"]
# TypeError: unsupported operand type(s) for +: 'int' and 'str'
Celui-là au moins plante. La version VICIEUSE :
total = ""
for row in csv.DictReader(f):
total += row["amount"]
print(total) # 54.3070.0023.1041.0065.50 ← concaténation, AUCUNE erreur
Correction : float(row["amount"]) à l'entrée (exemple 5). Règle : toute valeur sortant d'un CSV est présumée chaîne jusqu'à conversion explicite.
2) Oublier newline="" à l'écriture
with open("out.csv", "w", encoding="utf-8") as f: # newline="" manquant
writer = csv.writer(f)
...
Aucune erreur — mais sous Windows, le fichier a une ligne vide entre chaque ligne de données, et le prochain programme qui le lit voit une ligne sur deux vide. Correction : newline="" dans TOUS les open destinés à csv.
3) KeyError sur un en-tête qui « existe »
row["Amount"] # KeyError: 'Amount' — le fichier dit 'amount'
row["amount "] # les espaces comptent aussi !
Les clés de DictReader sont les en-têtes EXACTS : casse, espaces, accents. Et si le fichier vient d'Excel avec un BOM, la première clé peut être 'date'. Diagnostic en une ligne : print(reader.fieldnames). Corrections : la bonne casse, encoding="utf-8-sig" pour le BOM, ou nettoyer les en-têtes à l'entrée.
4) Le CSV « à la française » : séparateur ; et virgule décimale
# fichier : date;categorie;montant / 2026-06-01;courses;54,30
row = {'date;categorie;montant': '2026-06-01;courses;54,30'} # UNE seule colonne !
Excel français exporte avec ; (car la virgule sert de décimale). Symptôme : une seule clé géante contenant toute la ligne. Corrections : csv.DictReader(f, delimiter=";") et, pour les montants, float(value.replace(",", ".")).
5) Valeurs vides ou manquantes
float(row["amount"])
# ValueError: could not convert string to float: ''
Un CSV réel a des trous : cellule vide → "" (et une colonne entière manquante en fin de ligne → None avec DictReader). Correction : décider d'une politique AVANT de convertir — ignorer la ligne, compter 0, ou signaler :
raw = row["amount"]
if not raw: # "" et None sont tous deux falsy
continue # politique : ignorer les lignes sans montant
row["amount"] = float(raw)
6) Relire un reader déjà consommé
reader = csv.DictReader(f)
count = len(list(reader)) # 1er passage : consomme tout
for row in reader: # 2e passage : le reader est VIDE
print(row) # → cette boucle ne s'exécute JAMAIS
Aucune erreur, juste zéro tour de boucle — bug silencieux. Un reader est un flux, pas une liste. Correction : rows = list(csv.DictReader(f)) une fois, puis travailler sur rows.
7. Lire les messages d'erreur
ValueError à la conversion — trouver LA ligne fautive
Traceback (most recent call last):
File "C:\Users\jtron\Claude\learnpython\report.py", line 8, in <module>
row["amount"] = float(row["amount"])
~~~~~^^^^^^^^^^^^^^^
ValueError: could not convert string to float: '54,30'
Lecture :
- Dernière ligne : la valeur fautive est CITÉE —
'54,30'. Ici, virgule décimale à la française →.replace(",", ".")avantfloat. Si tu vois'', c'est une cellule vide ; si tu vois'N/A'ou'—', c'est un marqueur de donnée manquante à gérer. - Le traceback ne dit PAS quelle ligne du CSV a ce problème. Pour la trouver, enrichis ton chargement :
for line_number, row in enumerate(csv.DictReader(f), start=2): # 2 : après l'en-tête
try:
row["amount"] = float(row["amount"])
except ValueError:
print(f"Ligne {line_number} ignorée : montant invalide {row['amount']!r}")
UnicodeDecodeError — le mauvais encodage
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xe9 in position 42: invalid continuation byte
0xe9, c'est très souvent un é encodé en cp1252 qu'on essaie de lire en UTF-8 — export Excel français typique. Essaie encoding="cp1252". L'inverse (fichier UTF-8 lu en cp1252) ne plante pas mais affiche é : bug silencieux, vérifie tes sorties. Position 42 = le 43e octet du fichier, si tu veux aller regarder.
Le faux ami : pas d'erreur du tout. En CSV, les pires bugs ne lèvent rien : concaténation au lieu d'addition, lignes vides intercalées, une colonne géante à cause du ;. Ta défense : après CHAQUE écriture, ouvre le fichier produit (VS Code l'affiche très bien) et regarde 5 lignes ; après chaque lecture, print(len(rows)) et print(rows[0]). Trente secondes qui sauvent des heures.
8. Exercices — faciles
Règles : fichiers dans
exercices/mes-reponses/, nommésex-09-4-a.py, etc. Crée d'abordexpenses.csv(contenu de la section 5) dans le même dossier — à la main dans VS Code, c'est l'occasion de voir le format de près. Tout fonctionne hors ligne.
a) Lis expenses.csv avec DictReader et affiche, pour chaque ligne : courses : 54.30 € (catégorie puis montant). Puis affiche le nombre total de lignes. Vérifie au passage : quel est le type() de row["amount"] ?
b) Affiche uniquement les dépenses supérieures à 50 €, puis leur total, au format Total > 50 € : 189.80 € — deux chiffres après la virgule (:.2f). Piège au rendez-vous : la comparaison > 50 sur des chaînes ne plante pas toujours… mais ment. Convertis d'abord.
c) Écris top3.csv : les 3 plus grosses dépenses, colonnes date, category, amount, triées par montant décroissant. DictWriter + writeheader() obligatoires. Ouvre le fichier produit dans VS Code et vérifie : pas de ligne vide intercalée ? en-têtes présents ?
9. Exercices — moyens
a) Crée à la main expenses_dirty.csv : 8 lignes dont une avec montant vide, une avec 54,30 (virgule), et une avec la catégorie en majuscules (COURSES). Écris un chargeur robuste qui : normalise les catégories en minuscules, répare les virgules décimales, ignore les lignes sans montant en affichant Ligne N ignorée : ... (avec le vrai numéro de ligne du fichier), et renvoie des lignes aux types convertis. Affiche le total et le nombre de lignes gardées/ignorées.
b) Totaux par MOIS : à partir de expenses.csv enrichi (ajoute des dépenses en juillet), produis by_month.csv avec les colonnes month, total, count, average (moyenne arrondie à 2 décimales), triées par mois. La clé mois s'extrait de la date ("2026-06-01"[:7], ⏩ tranches : niveau 03).
c) Le convertisseur franco-anglais : lis un CSV « à la française » (séparateur ;, montants à virgule — crée expenses_fr.csv pour tester) et écris son équivalent standard (séparateur ,, montants à point), colonnes identiques. Les DEUX pièges de la section 6-4 doivent être traités. Bonus : gère aussi un fichier qui commence par un BOM.
10. Exercices — difficiles
a) Écris csv_report(path) : une fonction qui reçoit N'IMPORTE QUEL fichier CSV (colonnes inconnues à l'avance) et imprime un diagnostic : nombre de lignes, liste des colonnes, et pour chaque colonne : combien de valeurs vides, et si TOUTES ses valeurs non vides sont convertibles en nombre — dans ce cas min/max/moyenne. Teste-la sur expenses.csv ET sur un CSV que tu télécharges ou fabriques toi-même.
- Indice 1 :
reader.fieldnamesdonne les colonnes sans les deviner ; charge tout enlistpuis travaille colonne par colonne. - Indice 2 : « convertible en nombre » = un
try: float(v) except ValueErrordans une petite fonctionis_number(v)— pas de regex, pas d'astuce. - Indice 3 : structure — pour chaque colonne :
values = [row[col] for row in rows];empty = sum(1 for v in values if not v);numbers = [float(v) for v in values if v]dans un try global sur la colonne, ou filtre avecis_number.
b) Rapproche deux fichiers : expenses.csv (tes dépenses) et un budget.csv que tu crées (category,monthly_budget). Produis budget_check.csv : pour chaque catégorie, le budget, le dépensé, l'écart, et une colonne status (OK / DÉPASSÉ). Cas à gérer sans planter : une catégorie dépensée absente du budget (statut SANS BUDGET), une catégorie budgétée sans aucune dépense (dépensé = 0). C'est une jointure — le pain quotidien de l'analyse de données, ici à la main avec deux dicts.
11. Mini-projet lié — « rapport de dépenses »
(30–45 min.) Assemble les exercices en un vrai outil : expense_report.py lit un CSV de dépenses (fabrique-toi un mois réaliste, 25-40 lignes, avec quelques lignes sales), et produit :
report.txt— un rapport lisible : total du mois, top 3 des catégories, plus grosse dépense, nombre de lignes ignorées (⏩ écriture de fichiers texte : niveau 05) ;by_category.csvetby_month.csv— les synthèses (exemple 5) ;- sur la sortie standard : un résumé en 3 lignes.
Contraintes : fonctions séparées (charger / agréger / restituer), aucun plantage sur les lignes sales — elles sont comptées et signalées. Ce script est le prototype du « rapport automatique » de la leçon 09-5, où il apprendra à se lancer tout seul sur un dossier entier.
12. Correction / méthode de correction
Les solutions détaillées sont dans solutions/serie-09-4-solutions.md.
Méthode : (1) finis TOUS les exercices avant d'ouvrir les solutions ; (2) compare la position des conversions : si la solution convertit à l'entrée et toi au milieu des calculs, comprends pourquoi son choix tient mieux la route ; (3) ouvre côte à côte ton CSV produit et celui attendu — en-têtes, séparateurs, lignes vides ; (4) note dans ton journal les bugs SILENCIEUX rencontrés (concaténation, lignes vides…) : ils reviendront, et cette fois tu les reconnaîtras à l'œil.
13. À retenir
- Tout ce qui sort d'un CSV est une chaîne — convertir à l'entrée, une fois, avec une politique claire pour les valeurs vides/sales.
- L'ouverture rituelle :
open(path, newline="", encoding="utf-8")— lecture comme écriture. DictReader(lignes → dicts par en-têtes) etDictWriter(fieldnames=+writeheader()) par défaut ;reader/writersi pas d'en-têtes.- Agréger = dict accumulateur :
totals[k] = totals.get(k, 0) + valeur. - Un reader est un flux :
list(reader)pour repasser dessus. - Fichiers français :
delimiter=";"etvalue.replace(",", "."); BOM d'Excel :encoding="utf-8-sig". - Les pires bugs CSV sont silencieux : TOUJOURS inspecter le fichier produit et
print(rows[0])après lecture. - pandas existe et brille sur les gros volumes (⏩ hors méthode) — mais d'abord savoir faire à la main.
14. Questions de révision
- Pourquoi
sumsur une colonne de CSV peut-il produire du n'importe quoi SANS message d'erreur ? Deux variantes du bug ? - À quoi sert
newline=""et que se passe-t-il sous Windows sans lui ? DictReadervsreader: que renvoie chacun, et pourquoi préférer le premier quand il y a des en-têtes ?- Où placer les conversions de types dans un pipeline CSV, et pourquoi là ?
- Quels sont les deux pièges d'un CSV exporté d'Excel français, et leurs remèdes ?
- Que donne
float("")? Quelle politique appliques-tu aux cellules vides ? - Pourquoi une deuxième boucle sur le même
DictReaderne s'exécute-t-elle jamais ? - Quels sont les trois gestes indispensables pour écrire un CSV propre avec
DictWriter?
15. Checklist de compréhension
- J'ouvre un fichier CSV avec les bons arguments sans regarder la leçon.
- Je peux lire un CSV, convertir les montants et sortir un total juste.
- Je sais agréger par n'importe quelle colonne avec un dict accumulateur.
- J'écris un CSV de synthèse propre (en-têtes, pas de lignes vides) et je le VÉRIFIE à l'œil.
- Je survis à un CSV sale : cellule vide, virgule décimale,
;, mauvais encodage. - Je peux expliquer pourquoi on n'apprend pas pandas en premier.
- J'ai fait le mini-projet et le rapport sort juste sur MES données.
Si une case reste vide : refais les exercices de la section correspondante demain, AVANT d'ouvrir la leçon suivante.
16. Commit conseillé
git add lessons/level-09-automatisation-apis-donnees/exercices/mes-reponses/
git commit -m "exercises: complete lesson 09-4 CSV read write analyze"
Puis, si c'est ta fin de session : entrée de journal + git push.