Python : pandas.to_csv — Exporter propre, fiable et “Excel-friendly”
DataFrame.to_csv devient un contrat d’échange : lisible partout, stable dans le temps, compatible Excel, et sûr en production. Concentrez-vous sur les 7 décisions clés, appliquez une des recettes ci-dessus, et vous obtenez des CSV propres, légers et prédictibles.
1) Les 7 décisions à prendre avant d’exporter
- Qui lit votre CSV ?
– Excel Windows ? →encoding="utf-8-sig"(BOM) et souventsep=";"en contexte FR.
– Outil technique (ETL, db, microservice) ? →utf-8,sep=",", pas de BOM. - Index et en-têtes :
– Données tabulaires “propres” →index=False(souvent).
– Fichiers appendables →header=not fichier_existe. - Séparateur & culture :
– France/Excel :sep=";",decimal=","si vous voulez une virgule décimale.
– Monde/ingénierie :sep=",",decimal=".". - Reproductibilité :
– Ordre déterministe :df.sort_values([...])+columns=[...].
– Types maîtrisés : convertircategory/string/Int64avant export. - Taille & vitesse :
– Fichiers énormes →chunksize=...+ compression ("gzip","zip").
– Réseaux/cloud → compression + flux (S3/GCS viastorage_options). - Qualité & conformité :
– NA visibles ?na_rep="NA"(sinon chaîne vide).
– Champs texte susceptibles de contenir le séparateur →quoting=csv.QUOTE_MINIMAL(défaut) ouQUOTE_ALL. - Robustesse I/O :
– Append atomique et sûr → écrire dans un fichier temporaire puisrename.
– Eviter corruption en cas de crash →mode="w"+ écriture atomique.
2) Recettes prêtes à l’emploi
A. Export “Excel FR prêt-à-ouvrir”
import csv
df.to_csv(
"export_excel.csv",
sep=";", # point-virgule
decimal=",", # virgule décimale
index=False,
encoding="utf-8-sig", # BOM pour Excel Windows
quoting=csv.QUOTE_MINIMAL
)
B. Export “ingénierie/ETL” (classique, reproductible)
cols = ["id","client","date_vente","ca_ht"]
out = (df[cols]
.sort_values(["client","date_vente"])
.assign(date_vente=lambda d: d["date_vente"].dt.strftime("%Y-%m-%d")))
out.to_csv(
"dataset.csv",
sep=",",
index=False,
encoding="utf-8",
float_format="%.6f", # décimales stables
na_rep="" # NA vides (souvent attendu par ETL)
)
C. Append contrôlé (écrire la suite sans dupliquer les en-têtes)
from pathlib import Path
import csv
path = Path("log_ventes.csv")
out = df[["ts","client","montant"]].sort_values("ts")
out.to_csv(
path,
mode="a",
header=not path.exists(), # écrit l’en-tête une seule fois
index=False,
encoding="utf-8",
quoting=csv.QUOTE_MINIMAL,
line_terminator="\n"
)
D. Fractionner un gros dataset en sharding par mois
from pathlib import Path
Path("out").mkdir(exist_ok=True)
for mois, part in df.groupby(df["date"].dt.to_period("M")):
part.to_csv(f"out/ventes_{mois}.csv", index=False, encoding="utf-8")
E. Export compressé (taille ↓, réseau ↓)
# Gzip (extension .gz → compression auto-inférée)
df.to_csv("ventes.csv.gz", index=False, encoding="utf-8")
# ZIP avec nom interne contrôlé
df.to_csv(
"ventes.zip",
index=False,
encoding="utf-8",
compression={"method": "zip", "archive_name": "ventes.csv"}
)
F. Export vers S3 / GCS (fsspec)
df.to_csv(
"s3://mon-bucket/exports/ventes_2025-10.csv",
index=False,
encoding="utf-8",
storage_options={"anon": False} # adaptez vos credentials
)
G. Colonnes sensibles (zéros en tête, IDs très longs)
safe = (df.assign(
code_postal=df["code_postal"].astype("string").str.zfill(5),
id_str=df["id"].astype("string") # évite 1.23e+18
))
safe.to_csv("sortie.csv", index=False, encoding="utf-8")
H. Européen “strict” (virgule décimale + quoting partout)
import csv
df.to_csv(
"eu.csv",
sep=";",
decimal=",",
index=False,
encoding="utf-8",
quoting=csv.QUOTE_ALL, # TOUT entre guillemets
quotechar='"',
doublequote=True
)
3) Détails qui font la différence
Format des nombres & arrondis
float_format="%.2f"standardise l’affichage à 2 décimales.- Pour un rendu exact (finance) → envisagez
Decimalen amont, ou formatez des colonnes en chaînes avant export.
Dates stables & lisibles
- Convertissez vos
datetimeen chaîne avantto_csvsi vous visez un format imposé :df["date"] = df["date"].dt.strftime("%d/%m/%Y") # ou ISO: %Y-%m-%d
NA visibles vs silencieuses
na_rep="NA"rend les manquants explicites (utile en audit).- Chaîne vide par défaut : propre mais peut masquer des nulls fonctionnels.
Quoting, séparateurs et champs texte
- Si vos textes contiennent le séparateur ou des sauts de ligne, laissez
QUOTE_MINIMAL. - Pour forcer la sécurité maximale →
QUOTE_ALL. escapechar="\\"peut aider sur des datasets “bizarres”.
Terminateur de ligne & compatibilité
line_terminator="\n"(Unix) ;"\r\n"si un système cible l’exige.- Évitez les “double lignes” : avec pandas, pas besoin du
newline=""propre au modulecsvde Python.
Encodage & erreurs
encoding="utf-8"par défaut,utf-8-sigpour Excel,cp1252pour environnements très anciens.encoding_errors="replace"pour ne jamais planter (journaliser ensuite les anomalies).
4) Performance & gros volumes
Écriture par blocs
# Ecrit par paquets de 200k lignes (RAM ↘, régularité ↗)
df.to_csv("big.csv", index=False, chunksize=200_000)
Pré-tri et sous-ensemble de colonnes
- Écrire moins de colonnes (
columns=[...]) → disque ↓, réseau ↓. - Trier en amont peut accélérer des pipelines aval (diffs, merges).
Compression : le bon compromis
- gzip : simple, très courant (meilleur ratio que zip en général).
- zip : pratique pour Windows/Excel (double-clic), mais un peu plus lourd.
5) Patterns anti-bugs (copier-coller)
Écriture atomique (éviter les CSV tronqués)
import os, tempfile, shutil
def safe_to_csv(df, path, **kwargs):
d = os.path.dirname(path) or "."
with tempfile.NamedTemporaryFile("w", delete=False, dir=d, suffix=".tmp", encoding=kwargs.get("encoding","utf-8")) as tmp:
tmp_path = tmp.name
df.to_csv(tmp, **kwargs)
shutil.move(tmp_path, path) # rename atomique sur le même volume
safe_to_csv(df, "stable.csv", index=False, encoding="utf-8")
Append avec en-tête conditionnel
from pathlib import Path
def append_csv(df, path, **kwargs):
p = Path(path)
df.to_csv(p, mode="a", header=not p.exists(), **kwargs)
append_csv(df, "events.csv", index=False, encoding="utf-8")
Export “Excel-proof” (FR) factorisé
import csv
def to_csv_excel_fr(df, path):
df.to_csv(
path,
sep=";",
decimal=",",
index=False,
encoding="utf-8-sig",
quoting=csv.QUOTE_MINIMAL,
line_terminator="\n"
)
6) Erreurs fréquentes & parades
- Zéros en tête disparus (codes, SIRET) : la colonne était numérique → convertir en
string+zfillavant export. - IDs en notation scientifique : même cause → exporter en
string. - Colonnes mélangées (types incohérents) : normaliser les dtypes avant
to_csv. - Excel affiche du “charabia” : utiliser
encoding="utf-8-sig"oucp1252. - Séparateur mal interprété par Excel : préférer
sep=";"en locale FR. - Fichier corrompu après crash : adopter l’écriture atomique (temp + rename).
- Tailles gigantesques :
columns=[...],float_format,chunksize, compressiongzip.
7) Check-list opérationnelle (à coller dans vos projets)
- Public cible identifié (Excel FR vs ETL).
- Encodage choisi (
utf-8/utf-8-sig/cp1252). - Séparateur (
;FR /,global) & decimal (,ou.). - index=False (souvent) & header conditionnel en append.
- columns ordonnées + tri pour un export déterministe.
- float_format / date format stabilisés.
- na_rep explicite selon besoin.
- quoting adapté aux textes (minimal vs all).
- chunksize/compression pour gros volumes.
- écriture atomique pour la robustesse.
Cas pratique — Export multi-cibles avec pandas.to_csv
Ici un cas pratique complet autour de DataFrame.to_csv : un même DataFrame sert trois publics différents (Excel FR, ETL/warehouse, journal d’événements en append), avec contrôle qualité, robustesse I/O et performance.
Contexte métier
Une PME retail consolide des ventes quotidiennes issues de plusieurs boutiques. Les équipes :
- Commerce (FR/Excel) veulent un CSV “ouvrable direct” dans Excel Windows.
- Data/ETL veulent un CSV standardisé, reproductible, prêt pour ingestion.
- Ops gardent un journal d’événements (append) pour audit.
Contraintes réelles : séparateur FR ;, virgule décimale, NBSP dans les milliers, dates au format français, codes (CP, SIRET) à conserver avec zéros en tête, et volumes pouvant dépasser la RAM certains mois.
1) Ingestion & normalisation (rapide mais stricte)
import pandas as pd
from pathlib import Path
# 1) Lire plusieurs CSV "FR" (point-virgule, virgule décimale, NBSP pour milliers)
def read_csv_fr(path, *, usecols=None, dtypes=None, parse_dates=None):
return pd.read_csv(
path,
sep=";",
decimal=",",
thousands="\u00A0",
usecols=usecols,
dtype=dtypes,
parse_dates=parse_dates,
dayfirst=True,
na_values=["", "NA", "N/A", "-", "null"]
)
files = sorted(Path("data/ventes").glob("ventes_2025-0*.csv"))
use = ["date_vente", "magasin", "siret", "code_postal", "article", "qte", "prix_unitaire_ht", "tva"]
dtypes = {"magasin":"string","siret":"string","code_postal":"string","article":"string",
"qte":"Int64","prix_unitaire_ht":"float64","tva":"float64"}
df = pd.concat((read_csv_fr(f, usecols=use, dtypes=dtypes, parse_dates=["date_vente"]) for f in files),
ignore_index=True)
# 2) Nettoyages ciblés
df["code_postal"] = df["code_postal"].str.zfill(5)
df["siret"] = df["siret"].str.zfill(14)
# 3) Calculs métier
df["ca_ht"] = df["qte"] * df["prix_unitaire_ht"]
df["ca_ttc"] = df["ca_ht"] * (1 + df["tva"])
Contrôles minimaux (qualité) :
# Colonnes attendues / types clés
ATT = {"date_vente":"datetime64[ns]","qte":"Int64","ca_ht":"float64","ca_ttc":"float64"}
assert pd.api.types.is_datetime64_any_dtype(df["date_vente"])
assert pd.api.types.is_integer_dtype(df["qte"])
assert pd.api.types.is_numeric_dtype(df["ca_ht"]) and pd.api.types.is_numeric_dtype(df["ca_ttc"])
# Aucune ligne vide sur les identifiants clés
assert df["siret"].notna().all() and df["code_postal"].notna().all()
2) Export “Excel FR prêt-à-ouvrir”
Objectif : zéro surprise pour un utilisateur Excel Windows FR.
Choix : sep=";", decimal=",", BOM (utf-8-sig), index masqué, quoting minimal.
import csv
cols = ["date_vente","magasin","siret","code_postal","article","qte","prix_unitaire_ht","tva","ca_ht","ca_ttc"]
excel_df = (df[cols]
.sort_values(["date_vente","magasin","article"])
.assign(date_vente=lambda d: d["date_vente"].dt.strftime("%d/%m/%Y")))
excel_df.to_csv(
"exports/ventes_excel_fr.csv",
sep=";",
decimal=",", # Excel FR apprécie la virgule décimale
index=False,
encoding="utf-8-sig", # BOM pour Excel Windows
quoting=csv.QUOTE_MINIMAL,
lineterminator="\n"
)
Pourquoi ça marche dans Excel :
- BOM → Excel détecte correctement l’UTF-8.
;+decimal=","→ interprétation locale naturelle.- Dates déjà formatées en
DD/MM/YYYY.
3) Export “ETL/warehouse” reproductible
Objectif : standard international (, comme séparateur, . comme décimale), ISO 8601 pour les dates, tri et ordre de colonnes déterministes.
etl_cols = ["id_ligne","date_vente_iso","magasin","siret","article","qte","ca_ht","ca_ttc"]
etl_df = (df
.assign(
id_ligne=lambda d: d.index, # identifiant déterministe (ou hash si besoin)
date_vente_iso=lambda d: d["date_vente"].dt.strftime("%Y-%m-%d")
)[["id_ligne","date_vente_iso","magasin","siret","article","qte","ca_ht","ca_ttc"]]
.sort_values(["date_vente_iso","magasin","article","id_ligne"])
)
etl_df.to_csv(
"exports/ventes_dataset.csv",
sep=",",
index=False,
encoding="utf-8",
float_format="%.6f", # nombres stables
na_rep="" # NA vides (souvent attendu côté ingestion)
)
Variantes utiles :
- Compression réseau/stockage :
etl_df.to_csv("exports/ventes_dataset.csv.gz", index=False, encoding="utf-8") - Zip avec nom interne maîtrisé :
etl_df.to_csv( "exports/ventes_dataset.zip", index=False, encoding="utf-8", compression={"method": "zip", "archive_name": "ventes_dataset.csv"} )
4) Journal en append (audit) — en-tête écrit une seule fois
from pathlib import Path
import csv
log_path = Path("exports/journal_evenements.csv")
journal = (df[["date_vente","magasin","article","qte","ca_ht"]]
.sort_values(["date_vente","magasin"])
.assign(date_vente=lambda d: d["date_vente"].dt.strftime("%Y-%m-%d")))
journal.to_csv(
log_path,
mode="a",
header=not log_path.exists(), # en-tête la première fois seulement
index=False,
encoding="utf-8",
quoting=csv.QUOTE_MINIMAL,
lineterminator="\n"
)
5) Robustesse : écriture atomique (anti-fichiers tronqués)
En prod, on écrit dans un fichier temporaire puis on rename (opération atomique sur le même volume).
import os, tempfile, shutil
def safe_to_csv(df, path, **kwargs):
d = os.path.dirname(path) or "."
with tempfile.NamedTemporaryFile("w", delete=False, dir=d, suffix=".tmp",
encoding=kwargs.get("encoding","utf-8")) as tmp:
tmp_path = tmp.name
df.to_csv(tmp, **kwargs)
shutil.move(tmp_path, path)
safe_to_csv(excel_df, "exports/ventes_excel_fr_stable.csv",
sep=";", decimal=",", index=False, encoding="utf-8-sig", lineterminator="\n")
6) Gros volumes : lecture en morceaux → export streamé
Quand le CSV source dépasse la RAM, on lit en chunks et on écrit au fil de l’eau, en gardant l’en-tête pour le premier bloc uniquement.
from pathlib import Path
import csv
source = "data/ventes/ventes_2025-10.csv"
cible = Path("exports/ventes_2025-10_stream.csv")
first = True
for chunk in pd.read_csv(
source,
sep=";", decimal=",", thousands="\u00A0",
dtype=dtypes, parse_dates=["date_vente"], dayfirst=True,
chunksize=250_000):
# calculs essentiels sur le chunk
chunk["ca_ht"] = chunk["qte"] * chunk["prix_unitaire_ht"]
chunk["ca_ttc"] = chunk["ca_ht"] * (1 + chunk["tva"])
# export append
chunk.to_csv(
cible,
sep=";", decimal=",",
index=False,
encoding="utf-8-sig",
quoting=csv.QUOTE_MINIMAL,
lineterminator="\n",
header=first,
mode="a"
)
first = False
7) Sharding mensuel (fichiers par mois, prêts pour Excel)
from pathlib import Path
Path("exports/mois").mkdir(parents=True, exist_ok=True)
for per, part in df.groupby(df["date_vente"].dt.to_period("M")):
out = (part
.assign(date_vente=lambda d: d["date_vente"].dt.strftime("%d/%m/%Y"))
.sort_values(["date_vente","magasin","article"]))
out.to_csv(f"exports/mois/ventes_{per}.csv",
sep=";", decimal=",", index=False, encoding="utf-8-sig")
8) Tests rapides (sanity checks) après export
# 1) Re-lecture d’un échantillon de l’export Excel FR
probe = pd.read_csv("exports/ventes_excel_fr.csv", sep=";", decimal=",", nrows=100)
assert set(["ca_ht","ca_ttc"]).issubset(probe.columns)
# 2) Vérifier l’unicité d’un identifiant (ETL)
etl_probe = pd.read_csv("exports/ventes_dataset.csv", nrows=200_000)
assert etl_probe["id_ligne"].is_unique
# 3) Pas d’index exporté par inadvertance
with open("exports/ventes_excel_fr.csv", "r", encoding="utf-8-sig") as f:
header_line = f.readline().rstrip("\n")
assert not header_line.startswith("Unnamed:")
9) Erreurs fréquentes évitées ici
- Zéros en tête perdus (CP, SIRET) → colonnes forcées en
string+zfill. - Excel illisible →
utf-8-sig,sep=";",decimal=",". - Fichiers tronqués après panne → écriture atomique (tmp → rename).
- Volumes massifs → lecture par
chunksizeet append contrôlé. - Datasets non déterministes → tri + colonnes fixées + formats de dates et floats stabilisés.


