Avec pandas merge, des doublons dans les clés peuvent multiplier les lignes sans provoquer d’erreur. Une jointure gauche ne garantit pas une ligne de sortie par ligne d’entrée : une commande peut trouver plusieurs correspondances dans le référentiel client. Avant de supprimer des doublons, il faut définir la relation attendue et vérifier quelles données doivent être conservées.
Dans ce cas pratique, huit commandes deviennent dix lignes, et leur montant total augmente artificiellement. Nous allons isoler la cause, contrôler la cardinalité avec validate et repérer les commandes sans correspondance avec indicator. Deux CSV et le script complet sont téléchargeables.
Deux fichiers, une clé répétée et une clé absente
Le fichier des commandes comporte huit lignes. Le client 002 a passé deux commandes, ce qui est normal. Le référentiel contient aussi deux lignes pour ce client : une ancienne version du segment A et une version plus récente du segment B. Si nous joignons les deux tables directement, chaque commande de ce client trouve deux correspondances.
Le client 999 n’existe pas dans le référentiel. Une autre commande n’a pas d’identifiant client. Le référentiel contient lui aussi une ligne sans identifiant. Nous avons volontairement gardé ces défauts pour montrer qu’un contrôle de doublons ne suffit pas à sécuriser une jointure.
Les identifiants sont lus comme chaînes avec dtype={"client": "string"} afin de conserver les zéros initiaux. Les montants sont des entiers en centimes : le diagnostic ne dépend pas d’un arrondi flottant.
Résultat : 8 commandes, 10 lignes et un total artificiellement gonflé
| État | Lignes | Somme en centimes |
|---|---|---|
| Commandes à l’entrée | 8 | 83000 |
| Jointure naïve | 10 | 118000 |
| Jointure contrôlée | 7 | 79000 |
| Commande mise à part | 1 | 4000 |
La jointure naïve produit 10 lignes et 118 000 centimes, contre 83 000 centimes à l’entrée. Les deux commandes de 002, de 20 000 et 15 000 centimes, ont chacune été comptées une seconde fois. Les 35 000 centimes ajoutés ne correspondent à aucune nouvelle commande.
Après contrôle, sept commandes avec une clé renseignée restent dans la jointure, pour 79 000 centimes. La commande sans identifiant reste dans un fichier de quarantaine : une ligne et 4 000 centimes. Le rapprochement est exact : 79 000 + 4 000 = 83 000. « Quarantaine » signifie ici mise à part pour correction, pas suppression.
Déclarer la cardinalité attendue avant de corriger
Nous attendons une relation many_to_one : plusieurs commandes peuvent partager un client, mais une seule ligne du référentiel courant doit correspondre à ce client. L’argument validate="many_to_one" fait échouer la jointure si cette contrainte n’est pas respectée. C’est plus sûr que de vérifier seulement le résultat après coup.
La documentation pandas sur les jointures décrit les contrôles de cardinalité et les correspondances multiples. Une relation plusieurs-à-plusieurs peut être légitime, par exemple pour construire toutes les combinaisons attendues. Elle doit alors être voulue et son volume anticipé ; ce n’est pas notre règle métier.
Pour le référentiel, nous choisissons explicitement la version la plus récente de chaque client. Deux versions portant le même client et la même date déclenchent une erreur : le script ne devine pas laquelle est correcte. Cette règle décrit un état courant. Pour retrouver le segment qui était valable à la date de chaque commande, il faudrait une jointure temporelle ; garder simplement la dernière version pourrait utiliser une information future.
Traiter séparément les clés vides et les clients inconnus
Pandas peut faire correspondre deux clés nulles, comme l’indique l’avertissement de la référence pandas.merge. Ici, nous refusons donc les clés absentes ou composées d’espaces dans les fonctions de contrôle. Le programme principal isole les clés vides présentes dans les CSV générés avant la jointure. Avec vos propres fichiers, étendez cette étape aux conventions telles que « inconnu » ou « N/A » selon leur sens réel.
Le client 999 est un autre cas : sa clé est renseignée, mais non trouvée. Nous conservons sa commande grâce à how="left". La colonne _merge, obtenue avec indicator=True, la marque left_only ; les correspondances trouvées portent both. Un passage en jointure interne ferait disparaître cette commande du résultat.
Exécuter le diagnostic et les six tests
Télécharger le code et les fichiers — ZIP gratuit. Archive sans inscription, 6 tests, vérifiée après extraction. Dans un environnement Python dédié, installez python -m pip install -r requirements.txt, puis exécutez python jointure.py et python test_exemple.py. Le programme fonctionne localement après installation et ne transmet aucune donnée.
Le ZIP contient les deux CSV d’entrée, les sorties naïve et contrôlée, les quarantaines et le référentiel retenu. Les tests vérifient les erreurs attendues, le choix de version, les montants et la conservation des identifiants de commande.
Afficher le script Python complet
"""Jointure contrôlée : conserver les commandes, isoler les clés manquantes."""
from pathlib import Path
import json
import pandas as pd
def jeux():
commandes = pd.DataFrame({"commande": range(1, 9),
"client": ["001", "002", "002", "003", "004", "999", None, "001"],
"montant_centimes": [10000, 20000, 15000, 8000, 12000, 5000, 4000, 9000]})
clients = pd.DataFrame({"client": ["001", "002", "002", "003", "004", None],
"version": ["2026-09-01", "2026-09-01", "2026-10-01", "2026-09-01",
"2026-09-01", "2026-09-01"],
"segment": ["A", "A", "B", "B", "C", "inconnu"]})
return commandes, clients
def verifier_cles(table):
if table["client"].isna().any() or table["client"].astype("string").str.strip().eq("").any():
raise ValueError("Clé client absente : isoler ces lignes avant la jointure.")
def reference_recente(clients):
verifier_cles(clients)
ref = clients.copy()
ref["version"] = pd.to_datetime(ref["version"], format="%Y-%m-%d", errors="raise")
# Règle explicite : état courant du référentiel, pas historique à la date d'achat.
if ref.duplicated(["client", "version"]).any():
raise ValueError("Deux versions au même instant : arbitrage nécessaire.")
return ref.sort_values("version").drop_duplicates("client", keep="last")
def fusionner(commandes, clients):
verifier_cles(commandes)
verifier_cles(clients)
if commandes["commande"].duplicated().any():
raise ValueError("Identifiant de commande dupliqué.")
resultat = commandes.merge(clients, on="client", how="left",
validate="many_to_one", indicator=True)
if len(resultat) != len(commandes):
raise AssertionError("Nombre de commandes modifié.")
return resultat
def main():
dossier = Path(__file__).resolve().parent
commandes, clients = jeux()
commandes.to_csv(dossier / "commandes.csv", index=False)
clients.to_csv(dossier / "clients-versions.csv", index=False)
commandes = pd.read_csv(dossier / "commandes.csv", dtype={"client": "string"})
clients = pd.read_csv(dossier / "clients-versions.csv", dtype={"client": "string"})
naive = commandes.merge(clients, on="client", how="left")
# Ici les clés absentes sont réellement vides dans les CSV générés.
quarantaine = commandes[commandes["client"].isna()].copy()
valides = commandes[commandes["client"].notna()].copy()
ref_invalides = clients[clients["client"].isna()].copy()
reference = reference_recente(clients[clients["client"].notna()])
resultat = fusionner(valides, reference)
for nom, table in [("jointure-naive", naive), ("commandes-quarantaine", quarantaine),
("clients-quarantaine", ref_invalides), ("reference-courante", reference),
("jointure-controlee", resultat)]:
table.to_csv(dossier / (nom + ".csv"), index=False)
mesures = {"commandes_entree": len(commandes), "lignes_jointure_naive": len(naive),
"lignes_controlees": len(resultat), "commandes_quarantaine": len(quarantaine),
"clients_non_trouves": int((resultat["_merge"] == "left_only").sum()),
"total_entree_centimes": int(commandes["montant_centimes"].sum()),
"total_naif_centimes": int(naive["montant_centimes"].sum()),
"total_controle_centimes": int(resultat["montant_centimes"].sum()),
"total_quarantaine_centimes": int(quarantaine["montant_centimes"].sum())}
texte = json.dumps(mesures, ensure_ascii=False, indent=2)
(dossier / "resultats.json").write_text(texte + "\n", encoding="utf-8")
print(texte)
if __name__ == "__main__":
main()
Deux modifications pour vérifier que vous avez compris
Exercice 1. Ajoutez dans le référentiel une deuxième version du client 001 avec exactement la même date. Indice : le problème se situe avant le merge. Résultat attendu : reference_recente refuse l’ambiguïté. Ajouter un drop_duplicates sans arbitrage masquerait le problème.
Exercice 2. Remplacez temporairement la jointure gauche par une jointure interne. Indice : recherchez 999. Résultat attendu : cette commande disparaît et le total des commandes valides diminue de 5 000 centimes. Rétablissez ensuite la jointure gauche et les tests.
Pour adapter le script, remplacez explicitement la génération des exemples dans main() par la lecture de vos propres fichiers. Le script fourni régénère ses CSV à chaque lancement ; il ne faut pas écraser un CSV d’exemple en espérant le conserver au prochain passage.
La vérification à garder dans votre pipeline
Conservez un identifiant de ligne, déclarez la cardinalité attendue, contrôlez les clés et rapprochez les comptes ou les montants avant/après. Un total inchangé ne prouve pas à lui seul que toutes les correspondances sont correctes ; il complète les contrôles d’identifiants et la règle de sélection du référentiel.
Le guide des jointures pandas présente les variantes de merge. Pour vous entraîner sur d’autres défauts de fichiers, poursuivez avec les cinq exercices CSV gratuits avec corrigés.
Exemple original exécuté le 21 octobre 2026 avec Python 3.12.14. pandas 3.0.6. Les versions testées figurent dans le téléchargement. Miniature illustrative générée avec image_gen ; les résultats présentés proviennent du code exécuté. Notre méthode éditoriale.

