Tutorial10 min read

How to Scrape a Table From a Website

Short answer

The quickest way to pull an HTML table into a CSV file is one line of pandas: pd.read_html(url)[0].to_csv("table.csv", index=False). Yes, pandas.read_html handles it whenever the table is a real <table> in the HTML the server sends. It needs lxml, or Beautiful Soup with html5lib, and in the reference's words it "will always return a list of DataFrame or it will fail": one DataFrame per table, and to_csv writes the one you pick. Tables a page builds with JavaScript are not in that HTML; read the JSON request behind them instead. For tables across hundreds of pages, ProxyField residential proxies carry the requests from $2.95 for 1 GB (the Easy Trial).

residential IPs
412M+
from, traffic never expires
$1.95/GB
Easy Trial
1 GB for $2.95
On this page 9 sections
  1. What the pandas read_html reference says
  2. Which method fits your table
  3. Method 1: pandas.read_html with Requests
  4. Method 2: Beautiful Soup and the csv module
  5. Without code: Google Sheets and Excel
  6. Tables loaded by JavaScript
  7. Clean the data after you scrape it
  8. Proxies for table scraping at volume
  9. How to Scrape a Table From a Website FAQ
Shell
pip install pandas lxml
Python
import pandas as pd

url = "https://www.scrapethissite.com/pages/forms/"
tables = pd.read_html(url, storage_options={"User-Agent": "Mozilla/5.0"})   # a list of DataFrames, one per <table>
tables[0].to_csv("teams.csv", index=False)                                  # page 1: 25 rows, 9 columns

# several tables on the page? keep the one that contains a word
pd.read_html(url, match="Wins", storage_options={"User-Agent": "Mozilla/5.0"})[0].to_csv("wins.csv", index=False)

Three things to know before you run it:

  • It returns a list, always. Even a page with one table gives a one-item list, so index it with [0], or print [(i, t.shape) for i, t in enumerate(tables)] to find yours. To keep only one table, pass match="Wins" (tables containing that text) or attrs={"id": "results"}.
  • It misses JavaScript tables. On a page whose rows a script fills in, it raises ValueError: No tables found matching regex '.+'. The fix: open DevTools > Network > Fetch/XHR, find the request that returns the rows as JSON and read that with pd.DataFrame(requests.get(json_url).json()) (tables loaded by JavaScript). With only lxml installed, the same page raises ImportError: `Import html5lib` failed instead, because pandas falls back to Beautiful Soup + html5lib when lxml finds no table.
  • Some sites refuse the default request. A bare read_html(url) sends Python's urllib request; on 2026-10-27 this practice site answered it with HTTP Error 403: Forbidden. storage_options sends its pairs as request headers for HTTP(S) URLs (pandas 2.1 and later), which is why the example sets a User-Agent.

Sources the pandas read_html reference (pandas 3.1.0) and the HTML section of the pandas I/O guide. Every result on this page comes from a run on 2026-10-27 with pandas 3.1.0 and Python 3.12.

What the pandas read_html reference says

The full signature in pandas 3.1.0; every argument after io is keyword-only:

Python
pandas.read_html(io, *, match='.+', flavor=None, header=None, index_col=None, skiprows=None,
                 attrs=None, parse_dates=False, thousands=',', encoding=None, decimal='.',
                 converters=None, na_values=None, keep_default_na=True, displayed_only=True,
                 extract_links=None, dtype_backend=<no_default>, storage_options=None)

The reference's notes, in short:

  • A list or a failure: the function "will always return a list of DataFrame or it will fail", apart from rare cases, such as a single row whose <td> cells hold only whitespace.
  • What it reads: <table> elements, their <tr> and <th> rows and the <td> cells in them. It "attempts to properly handle colspan and rowspan attributes", and builds the header from <thead>, or from body rows that hold only <th> cells.
  • A parser is required: lxml, or Beautiful Soup with html5lib. With flavor=None it tries lxml first and falls back to Beautiful Soup with html5lib.
  • lxml and https: the reference notes that "lxml only accepts the http, ftp and file url protocols". In our run an https:// URL still worked with lxml alone, because pandas downloads the page itself; if a URL fails, fetch it with Requests and pass the text in StringIO.
  • Expect cleanup: "Expect to do some cleanup after you call this function", for example column names that come back as NaN with header=0. header is applied after skiprows.
ParameterDefaultWhat it doesExample
io(required)A URL, a file path or a file-like object; wrap HTML text in StringIOStringIO(html)
match'.+'Keeps only tables whose text matches this string or regexmatch="Wins"
flavorNoneThe parser: "lxml", "bs4" or "html5lib"; None tries lxml, then bs4 + html5libflavor="bs4" for broken markup
headerNoneRow(s) to use as column names; a list builds a two-level headerheader=[0, 1]
index_colNoneColumn(s) to use as the DataFrame indexindex_col=0
skiprowsNoneRows to skip before the header is readskiprows=1
attrsNoneKeeps only tables with these HTML attributesattrs={"class": "table"}
parse_datesFalseParses date columns, as in read_csvparse_dates=["Date"]
thousands, decimal',', '.'Number separatorsthousands=".", decimal="," for European formats
encodingNoneThe page's text encodingencoding="utf-8"
convertersNoneA function per column, applied to each cellconverters={"Price": lambda s: s.strip("$")}
na_values, keep_default_naNone, TrueExtra strings to read as missing; False replaces the default list instead of adding to itna_values=["N/A", "—"]
displayed_onlyTrueSkips elements styled display: nonedisplayed_only=False for a table hidden behind a tab
extract_linksNoneKeeps each link as a (text, href) pair in "header", "body", "footer" or "all"extract_links="body"
dtype_backendNumPy types"numpy_nullable" or "pyarrow" for nullable column types (experimental)dtype_backend="pyarrow"
storage_optionsNoneFor HTTP(S) URLs, sent as request headers (pandas 2.1+)storage_options={"User-Agent": "Mozilla/5.0"}

What the reference does not give you is a timeout, retries or a proxy: read_html(url) has no parameter for them. A job that needs those fetches the page with Requests and passes the text in (Method 1 below).

Which method fits your table

MethodCodeBest forLimits
pandas.read_html3 lines of PythonThe quickest table-to-CSV; clean tables, many pages, analysisNeeds a real <table>; JavaScript tables come back empty
Beautiful Soup + csv15 lines of PythonLinks in cells, merged cells, tables built from <div>sYou write the parsing
Google Sheets IMPORTHTMLA formulaA quick import of one public tablePublic pages only; no JavaScript; no headers or proxies
Excel Power Query (From Web)NoneTables you refresh inside ExcelDesktop Excel with Power Query; no JavaScript
The JSON behind the tablePython or any languageTables filled by JavaScriptYou have to find the request first

Before you choose, right-click the table and pick Inspect. If you see <table>, <tr> and <td> tags, every method works. If the rows are <div>s styled to look like a table, use Beautiful Soup with CSS selectors. And if View Source shows no rows at all, the table is loaded by JavaScript; skip to the section on that.

Method 1: pandas.read_html with Requests

Shell
pip install pandas lxml requests
Python
from io import StringIO

import pandas as pd
import requests

url = "https://www.scrapethissite.com/pages/forms/"
html = requests.get(url, params={"per_page": 100}, headers={"User-Agent": "Mozilla/5.0"}, timeout=30).text
tables = pd.read_html(StringIO(html))          # one DataFrame per <table>
df = tables[0]
print(len(tables), df.shape)                   # 1 (100, 9)
df.to_csv("teams.csv", index=False)

On 2026-10-27 this returned one table of 100 rows and 9 columns (team, year, wins, losses and so on), with numbers already typed as integers and floats. Notes from that run:

  • Wrap the HTML in StringIO. pandas 3.1.0 (current on PyPI on 2026-10-27; its package metadata requires Python 3.11 or later) treats a plain string as a file path: passing the HTML text directly failed with OSError: [Errno 36] File name too long.
  • Why Requests and not read_html(url). Requests gives you a timeout, retries through a Session, and a proxy; read_html(url) has none of the three.

Every page of a paginated table

When the table spans several pages, loop the page parameter and stop when a page comes back with no rows:

Python
import time

s = requests.Session()
s.headers["User-Agent"] = "Mozilla/5.0"
frames, page = [], 1
while True:
    r = s.get("https://www.scrapethissite.com/pages/forms/",
              params={"page_num": page, "per_page": 100}, timeout=30)
    df = pd.read_html(StringIO(r.text))[0]
    if df.empty:                       # past the last page: headers but no rows
        break
    frames.append(df)
    page += 1
    time.sleep(2)

teams = pd.concat(frames, ignore_index=True)
teams.to_csv("teams.csv", index=False)
print(teams.shape)                     # (582, 9)

It collected all 582 rows in 6 pages on 2026-10-27. Other paging patterns are in web scraping pagination.

Method 2: Beautiful Soup and the csv module

Use Beautiful Soup when you need what read_html throws away (the URLs behind cell links, images, data- attributes), or when the "table" is built from <div>s.

Python
import csv

import requests
from bs4 import BeautifulSoup

url = "https://www.scrapethissite.com/pages/forms/"
soup = BeautifulSoup(requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=30).text, "lxml")
table = soup.select_one("table.table")

headers = [th.get_text(strip=True) for th in table.select("tr th")]
rows = []
for tr in table.select("tr")[1:]:                      # skip the header row
    cells = [td.get_text(strip=True) for td in tr.select("td")]
    if cells:
        rows.append(cells)

with open("teams_page1.csv", "w", newline="", encoding="utf-8") as f:
    writer = csv.writer(f)
    writer.writerow(headers)
    writer.writerows(rows)
print(len(headers), "columns,", len(rows), "rows")      # 9 columns, 25 rows

To keep a link, read it alongside the text: td.select_one("a")["href"] when the cell has one. Merged cells need care: a cell with colspan="2" covers two columns and one with rowspan="2" covers two rows, so a naive loop shifts every later value one column to the left. Repeat the cell's value for its span, or use read_html, which expands spans for you. More selector techniques are in the Beautiful Soup tutorial.

Without code: Google Sheets and Excel

Google Sheets: =IMPORTHTML("https://example.com/page", "table", 1) imports the first table on the page; change the last number for the second, third and so on. For a CSV, choose File > Download > Comma-separated values (.csv). It works on public pages whose table is in the server's HTML, and you can't set headers or a proxy. Google's limits for these functions, their sibling IMPORTXML and refresh behavior are covered in web scraping with Google Sheets; check them there before you build on a formula.

Excel: Data > Get Data > From Other Sources > From Web, paste the URL, and the Navigator lists every table it found; load one and refresh it later with one click, or save the sheet as CSV. Step-by-step instructions, and what to do when Power Query finds nothing, are in web scraping with Excel. For Excel macros, see VBA web scraping.

Tables loaded by JavaScript

If View Source shows an empty <tbody> or a loading spinner, the rows arrive later from a script. That data almost always travels as JSON, which is easier to tabulate than HTML.

Open DevTools, go to Network > Fetch/XHR, reload the page and look for a response that holds the rows. On the practice page scrapethissite.com/pages/ajax-javascript/, clicking a year calls ?ajax=true&year=2015, which returns a JSON list of films:

Python
films = pd.DataFrame(requests.get(
    "https://www.scrapethissite.com/pages/ajax-javascript/",
    params={"ajax": "true", "year": 2015}, timeout=30).json())
print(films.shape)                     # (16, 5): title, year, awards, nominations, best_picture

If you can't reproduce the request, render the page in Playwright or Selenium, wait for the rows, and pass the rendered HTML to read_html. Both routes are explained in scrape dynamic web pages.

Clean the data after you scrape it

Scraped tables rarely arrive analysis-ready. The usual fixes in pandas:

  • Numbers stored as text because of symbols: df["Price"].str.replace(r"[$,]", "", regex=True).astype(float).
  • Percentages: strip % and divide by 100.
  • Footnote markers such as [1] or * in cells: df.replace(r"\[\d+\]", "", regex=True).
  • Repeated header rows in the middle of long tables: drop rows where a column equals its own header.
  • Empty cells come in as NaN (the "OT Losses" column above is empty for early seasons); decide whether that means zero or missing.

Proxies for table scraping at volume

One table is one request. Hundreds of pages of tables, or the same table checked every hour, is where sites start to limit requests per IP. Slow down first and respect the site's robots.txt and terms; if a per-IP limit still stops a lawful job, route the Requests session through a proxy:

Python
proxy = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
s.proxies.update({"http": proxy, "https": proxy})

gate.proxyfield.io:7000 is the ProxyField gateway; <login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. The gateway can give each request a new IP, or keep one IP for a sticky session from 30 s to 36 h. Table pages are mostly text, so a 1 GB Easy Trial ($2.95) covers thousands of them. Setup for other Python clients is on the Python proxy setup page.

Collecting tables from many pages? ProxyField residential proxies: 412M+ residential IPs in 195+ countries, targeting by country, region (state) and city, from $1.95/GB with no subscription; start with the Easy Trial (1 GB). Traffic never expires, and all purchases are final.

How to Scrape a Table From a Website FAQ

What's the quickest way to pull an HTML table off a website into a CSV file, and can pandas read_html handle it?

ProxyField's test on 2026-10-27 turned a 100-row, 9-column table into a CSV with three lines of pandas, so yes: pd.read_html(url)[0].to_csv("table.csv", index=False) is the quickest route when the page has a real <table>; add storage_options={"User-Agent": "Mozilla/5.0"} if the site refuses the default request. read_html returns a list of DataFrames, one per table, so pick yours with [0], match= or attrs=. It finds nothing in tables built by JavaScript; read the JSON request behind them, or render the page first.

How do I scrape tables from a website?

Fetch the page with Requests and pass it to pandas.read_html(StringIO(html)), which returns each <table> as a DataFrame you can save with to_csv. Use Beautiful Soup when you need the links inside cells, and Google Sheets IMPORTHTML or Excel Power Query for a quick import without code. If the table loads by JavaScript, read the JSON request behind it.

How do I scrape a table with pandas?

Download the HTML with Requests, then call pd.read_html(StringIO(html)) and take the table you want from the returned list, using match= or attrs= to pick it. In pandas 3.x, pass the HTML wrapped in StringIO; a plain string is read as a file path.

Why does pandas read_html return a list instead of one DataFrame?

Because a page can hold many tables, read_html always returns a list with one DataFrame per <table> it parsed, even when there is only one. Take the table with [0], or narrow the list first with match="some text" or attrs={"id": "..."}.

Why does pandas read_html say "No tables found"?

The HTML it received has no <table> with rows: either the rows are <div>s styled as a table, or a script fills the table after the page loads. The full message is ValueError: No tables found matching regex '.+' (with lxml); with only lxml installed you may see an html5lib ImportError instead, from the parser fallback. Check View Source; use Beautiful Soup with CSS selectors for <div> tables, and the JSON request or a rendered page for JavaScript tables.

What is an HTML web table?

It is data marked up with the <table> element: rows in <tr>, header cells in <th> and data cells in <td>, sometimes grouped in <thead> and <tbody>. Because the structure is explicit, tables are among the easiest page elements to scrape.

What kind of data can HTML tables contain?

Any tabular data: prices and specifications, sports statistics, financial figures, schedules, rankings, directories and comparison charts. Cells can also hold links, images and nested tables, which a simple text extraction loses.

What are the best tools to scrape HTML tables?

ProxyField's table tests of 2026-10-27 used the two code tools most people need: pandas read_html, the quickest, and Beautiful Soup, which gives the most control; without code, use Google Sheets IMPORTHTML or Excel Power Query. Browser extensions that export tables to CSV work for one-off copies, and a headless browser is needed only when the table is built by JavaScript. For hundreds of pages, ProxyField residential proxies ($2.95/GB at 1 GB, down to $1.95/GB from 1,000 GB) work with any of these tools.

What are JavaScript tables?

Tables whose rows are inserted by a script after the page loads, often from a JSON request, and sometimes paged or sorted in the browser. They are missing from the HTML the server sends, so read_html and IMPORTHTML find nothing; read the JSON request or render the page first.

Why is it difficult to parse tables in Python?

Real tables are messier than the markup suggests: merged cells (colspan, rowspan), multi-row headers, footnote markers, numbers stored as text and repeated header rows. read_html handles spans and types for clean tables, but awkward layouts still need Beautiful Soup and some cleanup in pandas.

Show 2 more questionsShow fewer questions

Why is table parsing difficult with Node.js?

Node.js has no built-in equivalent of pandas read_html, so you walk rows and cells yourself with a parser such as Cheerio and handle merged cells and number formats in your own code. The parsing itself is quick to write for a clean table; the edge cases take the time.

What is the easiest way to extract tables from HTML into JSON?

In Python, pd.read_html(StringIO(html))[0].to_json(orient="records") turns a table into a JSON list of row objects in one line. In Node.js, map each row's cells to an object keyed by the header texts with Cheerio.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.