Geonode logo
Geonode Team

Geonode Team

Mis à jour : 7 octobre 2026

Publié : 2 septembre 2026

pandas.read_html() : guide complet avec des exemples

`pandas.read_html()` Elle extrait les tableaux HTML en DataFrames en une seule ligne. Elle est vraiment utile, mais c’est aussi la fonction la plus susceptible de donner à un débutant une fausse impression de la facilité de l’extraction de données sur le Web. Elle renvoie une *liste* de DataFrames, et non un seul. Elle ne détecte que les éléments `<table>`. Et la documentation elle-même précise qu’il faut s’attendre à devoir nettoyer les données par la suite. Ce guide présente son fonctionnement, les paramètres à connaître et les cas dans lesquels il vaut mieux utiliser un autre outil.

Notre position : nous sommes Geonode et nous vendons des proxys ; les tableaux disponibles sur le Web public sont donc étroitement liés à notre activité. Il convient toutefois de noter que read_html, qui récupère directement une URL, ne vous offre aucun contrôle sur la requête — pas d’en-têtes personnalisés, pas de session, pas de logique de réessai, et aucun moyen de la rediriger via quoi que ce soit. Pour un tableau ponctuel sur un site coopératif, cela convient parfaitement et c’est la solution la plus rapide disponible. Pour tout ce qui est récurrent, récupérez le code HTML vous-même à l’aide d’un client HTTP adapté et transmettez la chaîne à read_html. Cette séparation ne coûte qu’une ligne de code supplémentaire et vous offre tout ce que la fonction fetch intégrée ne vous permet pas.

Les bases

import pandas as pd

tables = pd.read_html("https://example.com/data")
print(len(tables))
df = tables[0]

Le détail essentiel, tiré de la documentation de pandas : cette fonction renvoie « une liste de DataFrames ». Pas un seul DataFrame. Une page comportant six tableaux vous en donnera six, dans l’ordre où ils apparaissent dans le document, et tables[0]

correspondra probablement à une mise en page de navigation plutôt qu’aux données que vous recherchiez.

La documentation précise également clairement qu’elle « renverra toujours une liste de DataFrames ou échouera complètement » — elle ne renverra pas de liste vide, sauf dans des cas inhabituels tels qu’une « ligne unique dont l’<td>

ne contient que des espaces ». Un résultat vide est donc le signe qu’un incident s’est produit, et non un résultat normal.

Vous pouvez également transmettre directement du code HTML, ce qui est la méthode à privilégier pour tout ce qui va au-delà d’un simple aperçu :

import requests
html = requests.get(url, headers={"User-Agent": "MyBot/1.0 (+https://example.com/bot)"}).text
tables = pd.read_html(html)

Ce qu'il peut et ne peut pas voir

Comprendre son champ d'application permet d'éviter la plupart des déceptions.

Il lit uniquement les éléments <table>. La documentation précise qu’il « recherche les éléments <table> et ne traite que les lignes <tr>, <th> et les éléments <td> au sein des tableaux ». Une mise en page construite à partir d’éléments <div> stylisés comme une grille — ce qui correspond à la conception web la plus moderne — ne contient aucun tableau qu’il puisse trouver, même si elle ressemble à un tableau à l’écran.

Il n’exécute pas de JavaScript. Si le tableau est affiché côté client, le code HTML que l’read_htmle ne le contient pas. C’est la raison la plus courante pour laquelle l’e « n’a trouvé aucun tableau » sur une page qui en comporte visiblement un.

Il gère correctement les éléments « spanning ». Les attributs « colspan » et « rowspan » « sont gérés correctement », ce qui est plus que ce que parviennent à faire de nombreux analyseurs syntaxiques développés manuellement.

Il privilégie l’<thead> pour les en-têtes, et se rabat sur la recherche dans le corps du document s’il n’y en a pas.

Il respecte l’display: none par défaut. La valeur par défaut de displayed_only=True « exclut les éléments avec display: none », ce qui correspond généralement à ce que vous souhaitez et masque parfois des données qu’un site a délibérément rendues accessibles uniquement à certaines fenêtres d’affichage.

Deux remarques concernant les dépendances. Les moteurs d’analyse sont d’abord lxml, puis, à défaut, bs4 et html5lib — la documentation précise que « ‘bs4’ et ‘html5lib’ sont synonymes » en tant que noms de variantes. Il existe également une particularité concernant les URL qu’il est utile de connaître : « lxml n’accepte que les protocoles URL http, ftp et file. Si vous avez une URL commençant par 'https', vous pouvez essayer de supprimer le 's'. » En pratique, le fait de récupérer la page vous-même permet d’éviter complètement ce problème.

Les paramètres qui comptent

La signature complète comporte dix-huit paramètres. Six d’entre eux font l’essentiel du travail.

**match

** (par défaut '.+'

) filtre les tableaux dont le texte correspond à une expression régulière. C’est le paramètre le plus utile, et pourtant il est sous-utilisé :

tables = pd.read_html(html, match="Population")

Au lieu de deviner un index dans une liste, vous nommez un élément contenu dans le tableau. Cette méthode est bien plus robuste en cas de modification de la mise en page, car le contenu résiste généralement à une refonte qui déplace la position du tableau.

**attrs

** filtre en fonction des attributs HTML, ce qui constitue l’autre moyen d’identifier un tableau spécifique :

tables = pd.read_html(html, attrs={"id": "results", "class": "data"})

**header

** désigne la ligne d’en-tête. La documentation signale un détail d’ordre qui peut prêter à confusion : « l’argument header

est appliqué après l’application de skiprows

». Ainsi, si vous sautez deux lignes puis demandez header=0

, vous obtenez la première ligne après le saut.

**skiprows

** supprime les lignes avant l’analyse — utile pour les tableaux comportant des lignes de titre au-dessus de l’en-tête réel.

**index_col

** définit une colonne comme index.

**thousands

** (par défaut ','

) et **decimal

** (par défaut '.'

) gèrent le formatage numérique. Ces options ont plus d’importance qu’il n’y paraît : un tableau européen utilisant 1.234,56

nécessite thousands='.'

et decimal=','

; sans elles, chaque nombre est silencieusement converti en chaîne de caractères ou en une valeur erronée.

Deux autres éléments à connaître :

**converters

** applique une fonction par colonne au moment de l’analyse, ce qui est plus propre que de corriger les types a posteriori.

**extract_links

** capture l’href

des liens à l’intérieur des cellules plutôt que leur texte uniquement — ce qui est vraiment utile lorsque les lignes du tableau renvoient vers des pages de détail que vous souhaitez également inclure.

Le nettoyage que personne n'évite

La documentation définit clairement les attentes, et il vaut mieux lire la mise en garde plutôt que de la découvrir à ses dépens :

Préparez-vous à effectuer un peu de nettoyage après avoir appelé cette fonction. Par exemple, vous devrez peut-être attribuer manuellement des noms de colonnes si ceux-ci sont convertis en NaN lorsque vous passez l'argument ``header=0`

`.

Et :

Nous essayons de faire le moins de suppositions possible sur la structure du tableau et de laisser à l’utilisateur le soin de gérer les particularités du code HTML qu’il contient.

Cette deuxième phrase exprime clairement la philosophie de conception. read_html

vous fournit le contenu HTML tel quel ; c’est à vous de le mettre en ordre.

Le nettoyage qui revient à chaque fois :

Colonnes MultiIndex issues d’en-têtes sur plusieurs lignes. Un tableau avec un en-tête sur deux lignes produit un MultiIndex

, ce qui est correct mais peu pratique. Aplatissez-le :

df.columns = [" ".join(str(c) for c in col).strip() for col in df.columns]

Espaces blancs et espaces insécables. Le code HTML regorge d’&nbsp;

qui se transforment en \xa0

et empêchent le fonctionnement de la balise .strip()

:

df = df.replace("\xa0", " ", regex=True)
df.columns = df.columns.str.replace("\xa0", " ", regex=False).str.strip()

Chiffres sous forme de chaînes de caractères. Symboles monétaires, signes de pourcentage et marqueurs de notes de bas de page :

df["Price"] = (df["Price"].astype(str)
               .str.replace(r"[^\d.,-]", "", regex=True)
               .str.replace(",", "")
               .pipe(pd.to_numeric, errors="coerce"))

errors="coerce"

transforme les valeurs non analysables en NaN

plutôt que de générer une exception, ce qui vous permet de compter le nombre d’échecs plutôt que de perdre toute l’opération à cause d’une seule cellule erronée.

Lignes de notes de bas de page et totaux. De nombreux tableaux se terminent par une ligne de synthèse qui ne contient pas de données. Filtrez-la explicitement plutôt que de supposer que la dernière ligne est fiable.

Choisir la bonne table

Quatre approches, classées par ordre croissant de robustesse.

Par index — tables[0]

. Convient pour l'exploration, mais peu fiable dans un script. L'ajout d'une nouvelle table au-dessus de la vôtre provoque une rupture silencieuse, car l'index 0 existe toujours et contient désormais autre chose.

**Par match

** — le meilleur choix par défaut. Indiquez une chaîne de caractères qui apparaît uniquement dans la table souhaitée et nulle part ailleurs.

**Par attrs

** — idéal lorsque la table possède un identifiant ou une classe distinctive, car ceux-ci sont choisis délibérément par un développeur.

Par forme, après chargement — lorsque rien d’autre ne permet de l’identifier :

candidates = [t for t in pd.read_html(html)
              if {"Name", "Price"}.issubset(t.columns)]
if len(candidates) != 1:
    raise ValueError(f"expected 1 matching table, found {len(candidates)}")
df = candidates[0]

Cette assertion est la partie importante. Un script qui sélectionne silencieusement la première des trois correspondances produira des données erronées pendant des mois. Un échec lorsque le nombre de correspondances est inattendu transforme un problème de données en message d’erreur.

Lire plusieurs pages dans un seul DataFrame

C'est la suite logique une fois qu'un seul tableau fonctionne, et c'est là que quelques bonnes habitudes permettent d'éviter de réels problèmes.

Concaténez plutôt que d'ajouter en boucle. Construire un DataFrame de manière incrémentielle est lent et génère un index fragmenté. Collectez les frames et combinez-les en une seule fois :

frames = []
for page in range(1, 11):
    df = fetch_table(f"https://example.com/data?page={page}",
                     match="Population",
                     expected_columns=["Country", "Population"])
    df["source_page"] = page
    frames.append(df)

combined = pd.concat(frames, ignore_index=True)

Notez la provenance de chaque ligne. La colonne « source_page

» ci-dessus ne coûte rien et répond à la question qui vous sera inévitablement posée : quelle page a généré cette valeur inhabituelle ? Pour tout ce qui est collecté au fil du temps, ajoutez également un horodatage. Un ensemble de données sans provenance est très difficile à déboguer et impossible à auditer.

Espacez les requêtes. Récupérer dix pages aussi vite que la connexion le permet constitue un pic de trafic qui ressemble à une attaque du point de vue du serveur. Une pause d’une seconde entre chaque requête est à la fois une marque de courtoisie et, sur la plupart des sites, la différence entre mener la tâche à bien et se voir imposer une limitation de débit :

import time, random
time.sleep(1 + random.random())

**Gérez les échecs page par page plutôt que d’abandonner l’opération.**Une seule page dont la mise en page a changé ne devrait pas vous faire perdre les neuf autres :

frames, failures = [], []
for page in range(1, 11):
    try:
        frames.append(fetch_table(url_for(page), match="Population", expected_columns=COLS))
    except Exception as exc:
        failures.append((page, str(exc)))

if failures:
    print(f"{len(failures)} pages failed:", failures)

Vérifiez que les structures correspondent avant de concaténer. Si la page sept comporte une colonne que les autres n’ont pas, pd.concat

produira sans problème un cadre rempli de NaN

pour les lignes non correspondantes — valides, bien formées, mais erronées. Comparer les ensembles de colonnes avant de les combiner permet de transformer cela en une erreur visible.

Et éliminez les doublons par la suite. Les tableaux paginés répètent fréquemment des lignes d’une page à l’autre, en particulier lorsque les données sous-jacentes changent en cours de traitement. combined.drop_duplicates()

sur un sous-ensemble pertinent de colonnes constitue une protection peu coûteuse contre le comptage en double d’un même enregistrement.

Quand utiliser un autre outil ? «

read_html » est un wrapper pratique. Il existe quatre situations où il vaut mieux recourir à un autre outil.

Lorsque les données ne sont pas au format « <table> ». Mises en page de cartes, listes de définitions, grilles d’<div>. Utilisez un véritable parseur — lxml ou BeautifulSoup avec des sélecteurs CSS ou XPath — et construisez vous-même le DataFrame :

from lxml import html as lh
tree = lh.fromstring(page)
rows = [{"name": c.cssselect("h3")[0].text_content().strip(),
         "price": c.cssselect(".price")[0].text_content().strip()}
        for c in tree.cssselect("div.product-card")]
df = pd.DataFrame(rows)

Lorsque la page nécessite du JavaScript. Affichez-la d’abord avec Playwright ou un outil similaire, puis transmettez le code HTML affiché à read_html. Cette combinaison fonctionne bien et constitue souvent la solution la plus simple :

html = page.content()          # rendered DOM, not source
tables = pd.read_html(html)

Lorsque vous avez besoin de contrôler la requête. En-têtes, cookies, sessions, tentatives de reconnexion, délais d’expiration, proxys — aucun de ces éléments n’est exposé par read_html lorsqu’il effectue la requête à votre place. Effectuez la requête séparément et transmettez la chaîne de caractères.

Lorsque le site propose des données structurées. Un téléchargement au format CSV, une API ou du JSON-LD intégré à la page. N’importe laquelle de ces options est préférable à l’analyse du code HTML rendu en termes de stabilité, et cela vaut la peine de prendre trente secondes pour vérifier avant d’écrire quoi que ce soit.

Comment bien s'y prendre dans un script

Le modèle à suivre pour tout ce qui s'exécute plus d'une fois.

import pandas as pd
import requests

HEADERS = {"User-Agent": "AcmeDataBot/1.0 (+https://acme.example.com/bot)"}

def fetch_table(url, match, expected_columns):
    resp = requests.get(url, headers=HEADERS, timeout=30)
    resp.raise_for_status()

    tables = pd.read_html(resp.text, match=match)
    if len(tables) != 1:
        raise ValueError(f"{url}: expected 1 table matching {match!r}, got {len(tables)}")

    df = tables[0]
    df.columns = [str(c).replace("\xa0", " ").strip() for c in df.columns]

    missing = set(expected_columns) - set(df.columns)
    if missing:
        raise ValueError(f"{url}: missing columns {missing}; got {list(df.columns)}")

    if df.empty:
        raise ValueError(f"{url}: table matched but contains no rows")

    return df

Cinq éléments de ce modèle font la différence entre un script qui échoue de manière flagrante et un autre qui échoue discrètement.

**raise_for_status()

** intercepte les erreurs HTTP, car read_html

sur une page d'erreur ne trouvera soit aucun tableau, soit les mauvais.

**match

plutôt qu’un index**, de sorte qu’un changement de mise en page génère une erreur plutôt que de renvoyer la mauvaise table.

Vérification d’une correspondance unique, afin qu’un résultat ambigu soit interrompu plutôt que de prendre silencieusement le premier.

Vérification des colonnes attendues, ce qui permet de détecter une refonte qui les renomme ou les réorganise — une erreur qui, sinon, produirait indéfiniment des données incorrectes mais bien formées.

Vérification de l’absence de contenu, car une table correspondante sans ligne est presque toujours un symptôme plutôt qu’un résultat.

Un agent utilisateur honnête disposant d’une URL de contact ne coûte rien et fait de vous un client qu’un opérateur peut choisir d’autoriser plutôt qu’un client qu’il est obligé de bloquer.

Questions fréquentes

Que fait la fonction pandas.read_html ?

Elle analyse le code HTML et renvoie chaque élément <table> qu’elle trouve sous la forme d’une liste de DataFrames. Elle prend en charge les balises colspan et rowspan, utilise <thead> pour les en-têtes lorsqu’ils sont présents, et exclut par défaut les éléments masqués par display: none.

Pourquoi read_html renvoie-t-il une liste ?

Parce qu’une page peut contenir un nombre indéfini de tableaux, et que pandas les renvoie tous dans l’ordre du document. Il renvoie toujours une liste ou échoue — il ne renvoie pas de liste vide, sauf dans des cas exceptionnels — ; un résultat vide est donc en soi le signe qu’un problème s’est produit.

Pourquoi read_html affiche-t-il « Aucun tableau trouvé » ?

Soit la page ne contient aucun élément <table> — les mises en page modernes utilisent souvent à la place des éléments <div> stylisés —, soit le tableau est généré par du JavaScript que pandas n’exécute pas. Vérifiez le code HTML brut plutôt que l’affichage rendu par le navigateur pour déterminer la cause.

Comment sélectionner un tableau spécifique ?

Utilisez match avec une expression régulière correspondant au texte contenu dans le tableau souhaité, ou attrs pour filtrer par identifiant (id) ou classe. Ces deux méthodes sont bien plus fiables que l’indexation dans la liste, qui cesse de fonctionner sans avertissement lorsqu’un tableau est ajouté au-dessus du vôtre.

La fonction read_html fonctionne-t-elle avec les pages rendues par JavaScript ?

Non. Elle analyse le code HTML et n’exécute pas les scripts. Affichez d’abord la page à l’aide d’un outil d’automatisation de navigateur, puis transmettez la chaîne HTML obtenue à read_html — cette combinaison fonctionne bien et constitue généralement la solution la plus simple.

Comment nettoyer le DataFrame par la suite ?

Prévoyez d’aplatir les colonnes MultiIndex issues d’en-têtes sur plusieurs colonnes, de supprimer les espaces insécables qui apparaissent sous la forme \xa0, de convertir les chaînes de devises et de pourcentages en nombres à l’aide de pd.to_numeric(errors="coerce"), et de supprimer les lignes de notes de bas de page ou de totaux. La documentation de pandas indique explicitement qu’un nettoyage est à prévoir.

Puis-je utiliser un proxy ou des en-têtes personnalisés avec read_html ?

Pas lorsque la fonction récupère l’URL elle-même — elle ne propose aucune option de requête. Récupérez la page à l’aide de requests ou d’un autre client, où vous contrôlez les en-têtes, les délais d’expiration, les sessions et les proxys, puis transmettez la chaîne HTML à read_html.

À quoi servent les paramètres « thousands » et « decimal » ?

Ils indiquent à pandas comment formater les nombres ; par défaut, ils prennent respectivement les valeurs « ',' » et « '.' ». Pour un formatage européen tel que « 1.234,56 », vous devez utiliser « thousands='.' » et « decimal=',' » — sans eux, les valeurs sont analysées de manière incorrecte sans message d’erreur ou conservées sous forme de chaînes de caractères.

Conclusion : «

read_html » est une fonction très pratique, bien que son champ d’application soit restreint : elle identifie les éléments <table> dans le code HTML que vous lui fournissez et les transforme en DataFrames. Dans ce cadre précis, elle gère les aspects complexes — cellules étendues, détection des en-têtes, éléments masqués — mieux que la plupart des analyseurs syntaxiques écrits à la main.

Il y a deux points à retenir : d’une part, elle renvoie une liste plutôt qu’un DataFrame, et d’autre part, la documentation elle-même vous indique de vous attendre à un nettoyage. En effectuant une sélection via match ou attrs plutôt que par index, et en vérifiant qu’un seul tableau correspond, vous transformez l’échec silencieux le plus courant en un message d’erreur.

Pour tout ce qui s’exécute plus d’une fois, récupérez le code HTML vous-même. Une ligne supplémentaire vous permet de bénéficier d’en-têtes, de délais d’expiration, de tentatives de reconnexion, de sessions et de tout ce que la fonction fetch intégrée ne fournit pas — tout en contournant la particularité du protocole URL de lxml.

Et lorsque la page ne contient pas de tableaux, cessez de rechercher des paramètres. Une grille <div> ou une page rendue côté client constitue un problème différent, et la solution réside dans un véritable analyseur syntaxique, une étape de rendu ou — mieux encore — les données structurées que le site publie probablement déjà quelque part où vous n’avez pas encore cherché.