Geonode logo
Geonode Team

Geonode Team

Actualizado: 7 de octubre de 2026

Publicado: 2 de septiembre de 2026

pandas.read_html(): Guía completa con ejemplos

`pandas.read_html()` Extrae tablas HTML a DataFrames en una sola línea. Es realmente útil y, además, es la función que más probabilidades tiene de dar a un principiante una falsa sensación de lo fácil que es la extracción de datos de la web. Devuelve una *lista* de DataFrames, no uno solo. Solo encuentra elementos ``<table>``. Y la propia documentación indica que hay que realizar una limpieza posterior. Esta guía explica qué hace, los parámetros que conviene conocer y los casos en los que es mejor utilizar otra herramienta.

Nuestra postura: somos Geonode y vendemos proxies, por lo que las tablas de la web pública están directamente relacionadas con nuestro negocio. La advertencia relevante es que read_html al recuperar una URL directamente no te da ningún control sobre la solicitud: ni encabezados personalizados, ni sesión, ni lógica de reintentos, ni forma de redirigirla a través de nada. Para una tabla puntual en un sitio colaborativo, eso está bien y es la opción más rápida disponible. Para cualquier cosa recurrente, recupera tú mismo el HTML con un cliente HTTP adecuado y pasa la cadena a read_html. Esa separación supone una línea adicional y te ofrece todo lo que la función fetch integrada no te permite.

Conceptos básicos

import pandas as pd

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

El detalle clave, según la documentación de pandas: devuelve «una lista de DataFrames». No un solo DataFrame. Una página con seis tablas te dará seis, en el orden en que aparecen en el documento, y tables[0]

bien podría ser un esquema de navegación en lugar de los datos que buscabas.

La documentación también deja claro que «siempre devolverá una lista de DataFrames o fallará por completo»: no devolverá una lista vacía salvo en casos excepcionales, como «una sola fila con un <td>

que contenga únicamente espacios en blanco». Por lo tanto, un resultado vacío es una señal de que ha ocurrido algo extraño, más que un resultado normal.

También puedes pasar HTML directamente, que es el formato preferible para cualquier cosa que vaya más allá de un vistazo rápido:

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

Lo que puede y no puede ver

Conocer el alcance evita la mayoría de las decepciones.

Solo lee elementos <table>. La documentación indica que «busca elementos <table> y solo procesa las filas <tr>, <th> y los elementos <td> dentro de las tablas». Un diseño creado a partir de elementos <div> con estilo de cuadrícula —que es lo más habitual en el diseño web moderno— no contiene ninguna tabla que pueda encontrar, independientemente de lo parecida que sea a una tabla en pantalla.

No ejecuta JavaScript. Si la tabla se genera del lado del cliente, el código HTML que ve read_html no la contiene. Esta es la razón más habitual por la que «no se han encontrado tablas» en una página que, a simple vista, tiene una.

Gestiona correctamente los elementos «spanning». Los atributos «colspan» y «rowspan» «se gestionan correctamente», lo cual es más de lo que consiguen muchos analizadores sintácticos creados a mano.

Da prioridad a <thead> para los encabezados y, si no hay ninguno, recurre a buscarlos en el cuerpo.

Respeta display: none por defecto. El valor predeterminado de «displayed_only=True» «excluye los elementos con display: none», que suele ser lo que se desea y, en ocasiones, oculta datos que un sitio ha puesto deliberadamente a disposición solo para determinadas ventanas de visualización.

Dos notas sobre las dependencias. Los motores de análisis son, en primer lugar, lxml, y, en su defecto, bs4 y html5lib; la documentación señala que «"bs4" y "html5lib" son sinónimos» como nombres de variantes. Y hay una peculiaridad de las URL que conviene conocer: «lxml solo acepta los protocolos URL http, ftp y file. Si tienes una URL que empieza por 'https', puedes intentar eliminar el 's'». En la práctica, si recuperas la página tú mismo, evitas este problema por completo.

Los parámetros que importan

La firma completa tiene dieciocho parámetros. Seis de ellos realizan la mayor parte del trabajo.

**match

** (por defecto, '.+'

) filtra las tablas cuyo texto coincida con una expresión regular. Este es el parámetro más útil con diferencia y, sin embargo, se utiliza muy poco:

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

. En lugar de adivinar un índice dentro de una lista, se especifica algo que contiene la tabla. Es mucho más robusto cuando cambia el diseño de una página, ya que el contenido suele sobrevivir a un rediseño que cambie la posición de la tabla.

**attrs

** filtra por atributos HTML, que es la otra forma de identificar una tabla específica:

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

**header

** especifica el nombre de la fila de encabezado. La documentación señala un detalle sobre el orden que suele confundir a los usuarios: «el argumento header

se aplica después de que se aplique skiprows

». Así pues, si se saltan dos filas y luego se solicita header=0

, se obtiene la primera fila después del salto.

**skiprows

** elimina filas antes del análisis, lo cual resulta útil para tablas con filas de título situadas por encima del encabezado real.

**index_col

** establece una columna como índice.

**thousands

** (por defecto, ','

) y **decimal

** (por defecto, '.'

) se encargan del formato numérico. Esto es más importante de lo que parece: una tabla europea que utilice 1.234,56

necesita thousands='.'

y decimal=','

; sin ellos, todos los números se convierten silenciosamente en cadenas de caracteres o en valores erróneos.

Hay otras dos que conviene conocer:

**converters

** aplica una función por columna en el momento del análisis, lo cual es más limpio que corregir los tipos a posteriori.

**extract_links

** captura la href

de los enlaces dentro de las celdas en lugar de solo su texto —algo realmente útil cuando las filas de la tabla enlazan con páginas de detalles que también te interesan—.

La limpieza que nadie evita

La documentación establece las expectativas con sinceridad, y merece la pena leer el aviso legal antes de descubrirlo por uno mismo:

Ten en cuenta que tendrás que realizar algunas tareas de limpieza después de llamar a esta función. Por ejemplo, es posible que tengas que asignar manualmente los nombres de las columnas si estos se convierten en NaN al pasar el argumento ``header=0`

`.

Y:

Intentamos hacer el menor número posible de suposiciones sobre la estructura de la tabla y dejamos en manos del usuario las peculiaridades del HTML que contiene.

Esa segunda frase es la filosofía de diseño expresada sin rodeos. read_html

te ofrece lo que contiene el HTML; ponerlo en orden es tarea tuya.

La limpieza que surge cada vez:

Columnas MultiIndex de encabezados que abarcan varias filas. Una tabla con un encabezado de dos filas genera un MultiIndex

, lo cual es correcto pero poco elegante. Aplanalo:

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

Espacios en blanco y espacios no separables. El HTML está lleno de &nbsp;

, que se convierte en \xa0

y hace que el sencillo .strip()

no funcione:

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

Números que son cadenas de caracteres. Símbolos monetarios, signos de porcentaje y marcadores de notas al pie:

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

errors="coerce"

convierte los valores imposibles de analizar en NaN

en lugar de generar un error, lo que te permite contar cuántos han fallado en lugar de perder toda la operación por una sola celda errónea.

Filas de notas al pie y totales. Muchas tablas terminan con una fila de resumen que no contiene datos. Filtrala explícitamente en lugar de dar por sentado que la última fila es segura.

Seleccionar la tabla adecuada

Cuatro enfoques, ordenados de menor a mayor solidez.

Por índice — tables[0]

. Vale para explorar, pero es inestable en un script. Si se añade una nueva tabla por encima de la tuya, esta deja de funcionar sin avisar, porque el índice 0 sigue existiendo y ahora contiene otra cosa.

**Por «match

»** — la mejor opción por defecto. Indica una cadena que aparezca en la tabla que deseas y en ninguna otra.

**Por «attrs

»** — ideal cuando la tabla tiene un identificador o una clase distintiva, ya que estos son elegidos deliberadamente por un desarrollador.

Por forma, tras la carga — cuando nada más la identifica:

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]

Esa comprobación es la parte importante. Un script que seleccione silenciosamente la primera de tres coincidencias generará datos erróneos durante meses. Que falle cuando el recuento sea inesperado convierte un problema de datos en un mensaje de error.

Leer varias páginas en un solo DataFrame

El siguiente paso lógico una vez que la tabla individual funciona, y el momento en el que unos cuantos hábitos te ahorran verdaderos problemas.

Concatenar en lugar de añadir en un bucle. Crear un DataFrame de forma incremental es lento y genera un índice fragmentado. Recoge los marcos y combínalos de una sola vez:

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)

Anota de dónde procede cada fila. La columna «source_page

» anterior no cuesta nada y responde a la pregunta que, tarde o temprano, te harán: ¿qué página generó este valor extraño? Para cualquier dato recopilado a lo largo del tiempo, añade también una marca de tiempo. Un conjunto de datos sin procedencia es muy difícil de depurar e imposible de auditar.

Regula el ritmo del bucle. Obtener diez páginas tan rápido como lo permita la conexión es un pico de tráfico que, desde el punto de vista del servidor, puede parecer un ataque. Una pausa de un segundo entre solicitudes es una muestra de cortesía y, en la mayoría de los sitios web, marca la diferencia entre completar la tarea y sufrir una limitación de velocidad:

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

**Gestiona los errores página por página en lugar de abandonar la ejecución.**Que una página haya cambiado de diseño no debería hacerte perder las otras nueve:

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)

Comprueba que las estructuras coincidan antes de concatenarlas. Si la página siete tiene una columna que las demás no tienen, pd.concat

generará sin problemas un marco lleno de NaN

para las filas que no coinciden —válidas, bien formadas, pero erróneas—. Comparar los conjuntos de columnas antes de combinarlos convierte eso en un error que puedes detectar.

Y elimina los duplicados después. Las tablas paginadas suelen repetir filas entre páginas, sobre todo cuando los datos subyacentes cambian a mitad del rastreo. combined.drop_duplicates()

en un subconjunto significativo de columnas es una medida sencilla para evitar contar el mismo registro dos veces.

Cuándo utilizar otra cosa «

read_html» es una herramienta de conveniencia. Hay cuatro situaciones en las que conviene utilizar una herramienta diferente.

Cuando los datos no están en un «<table>». Diseños de tarjetas, listas de definiciones, cuadrículas de «<div>». Utiliza un analizador real —lxml o BeautifulSoup con selectores CSS o XPath— y crea el DataFrame tú mismo:

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)

Cuando la página necesita JavaScript. Primero, renderízala con Playwright o una herramienta similar; después, pasa el HTML renderizado a read_html. Esa combinación funciona bien y suele ser la vía más rápida:

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

Cuando necesites controlar la solicitud. Encabezados, cookies, sesiones, reintentos, tiempos de espera, proxies… Ninguno de estos elementos los expone read_html al realizar la solicitud por ti. Realiza la solicitud por separado y pasa la cadena.

Cuando el sitio ofrece datos estructurados. Una descarga en CSV, una API o JSON-LD incrustado en la página. Cualquiera de estas opciones es mejor que analizar el HTML renderizado en cuanto a estabilidad, y merece la pena dedicar treinta segundos a comprobarlo antes de escribir nada.

Cómo hacerlo correctamente en un script

El patrón para cualquier cosa que se ejecute más de una vez.

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

Hay cinco aspectos ahí que marcan la diferencia entre un script que falla de forma evidente y uno que falla de forma silenciosa.

**raise_for_status()

** detecta los errores HTTP, ya que read_html

en una página de error no encontrará ninguna tabla o encontrará las tablas equivocadas.

**match

en lugar de un índice**, de modo que un cambio en el diseño genere un error en lugar de mostrar la tabla incorrecta.

Comprobar que haya exactamente una coincidencia, de modo que un resultado ambiguo se detenga en lugar de aceptar silenciosamente el primero.

Comprobando las columnas esperadas, lo que detecta un rediseño que las renombra o reordena —el fallo que, de otro modo, produciría indefinidamente datos incorrectos pero bien formados.

Comprobando si están vacías, ya que una tabla coincidente sin filas es casi siempre un síntoma más que un resultado.

Un agente de usuario honesto con una URL de contacto no cuesta nada y hace que el operador pueda optar por admitirte como cliente, en lugar de verse obligado a bloquearte.

Preguntas frecuentes

¿Qué hace pandas.read_html?

Analiza el código HTML y devuelve cada elemento <table> que encuentra como una lista de DataFrames. Gestiona colspan y rowspan, utiliza <thead> para los encabezados cuando están presentes y, por defecto, excluye los elementos ocultos con display: none.

¿Por qué devuelve read_html una lista?

Porque una página puede contener cualquier número de tablas, y pandas las devuelve todas en el orden en que aparecen en el documento. Siempre devuelve una lista o falla —no devolverá una lista vacía salvo en casos excepcionales—, por lo que un resultado vacío es en sí mismo una señal de que algo ha salido mal.

¿Por qué read_html muestra el mensaje «No se han encontrado tablas»?

O bien la página no tiene elementos <table> —los diseños modernos suelen utilizar en su lugar elementos <div> con estilo— o bien la tabla se genera mediante JavaScript que pandas no ejecuta. Comprueba el código fuente HTML sin procesar, en lugar de la vista generada por el navegador, para determinar cuál de las dos opciones es la correcta.

¿Cómo selecciono una tabla concreta?

Utiliza match con una expresión regular que coincida con el texto dentro de la tabla que desees, o attrs para filtrar por un id o una clase. Ambas opciones son mucho más robustas que indexar la lista, lo cual falla de forma silenciosa cuando se añade una tabla por encima de la tuya.

¿Funciona read_html con páginas renderizadas con JavaScript?

No. Analiza el HTML y no ejecuta scripts. Primero renderiza la página con una herramienta de automatización del navegador y, a continuación, pasa la cadena HTML resultante a read_html: esa combinación funciona bien y suele ser la vía más rápida.

¿Cómo limpio el DataFrame después?

Ten en cuenta que tendrás que aplanar las columnas MultiIndex que contengan encabezados que abarcan varias columnas, eliminar los espacios no separables que aparecen como \xa0, convertir las cadenas de moneda y porcentaje a números con pd.to_numeric(errors="coerce"), y eliminar las filas de notas al pie o de totales. La documentación de pandas indica explícitamente que hay que realizar una limpieza.

¿Puedo utilizar un proxy o encabezados personalizados con read_html?

No cuando la propia función read_html recupera la URL, ya que no expone opciones de solicitud. Recupera la página con requests u otro cliente, donde puedas controlar los encabezados, los tiempos de espera, las sesiones y los proxies, y luego pasa la cadena HTML a read_html.

¿Para qué sirven los parámetros «thousands» y «decimal»?

Indican a pandas cómo se formatean los números; por defecto, se utilizan ',' y '.', respectivamente. Para el formato europeo, como 1.234,56, necesitas thousands='.' y decimal=','; sin ellos, los valores se analizan incorrectamente de forma silenciosa o se dejan como cadenas de caracteres.

Conclusión: «

read_html» es una función realmente útil con un ámbito de aplicación limitado: busca elementos «<table>» en el código HTML que se le proporcione y los convierte en DataFrames. Dentro de ese ámbito, gestiona los aspectos más complicados —celdas que abarcan varias filas, detección de encabezados, elementos ocultos— mejor que la mayoría de los analizadores sintácticos escritos a mano.

Hay dos cosas que hay que tener muy en cuenta: que devuelve una lista en lugar de un DataFrame, y que la propia documentación indica que hay que esperar una limpieza de datos. Seleccionar mediante match o attrs en lugar de por índice, y comprobar que solo coincida una tabla, convierte el fallo silencioso más habitual en un mensaje de error.

Para cualquier cosa que se ejecute más de una vez, recupera el HTML tú mismo. Una línea adicional te proporciona encabezados, tiempos de espera, reintentos, sesiones y todo lo demás que la función fetch integrada omite —y, al mismo tiempo, evita la peculiaridad del protocolo URL de lxml.

Y cuando la página no tenga tablas, deja de buscar parámetros. Una cuadrícula de <div> o una página renderizada por el cliente es un problema diferente, y la solución es un analizador sintáctico real, un paso de renderización o —lo mejor de todo— los datos estructurados que el sitio probablemente ya esté publicando en algún lugar donde aún no hayas mirado.