Knowledge Base
How to Scrape a Table Using Beautifulsoup
A focused Python tutorial on parsing HTML tables with BeautifulSoup, pulling headers and rows into structured data ready for CSV or a pandas DataFrame.
Knowledge Base
A focused Python tutorial on parsing HTML tables with BeautifulSoup, pulling headers and rows into structured data ready for CSV or a pandas DataFrame.
Tables are everywhere on the web: pricing grids, statistics, schedules and comparison charts. Turning that <table> markup into clean rows and columns is a classic BeautifulSoup task, and once you understand the structure it is very repeatable.
This guide shows how to locate a table, read its header cells, iterate over its rows, and export the result to CSV or a pandas DataFrame for analysis.
Extracting table rows is step one; usable data needs cleaning. Strip currency symbols and thousands separators before converting to numbers, expand rowspan and colspan cells algorithmically so every row aligns, handle multi-row headers, and validate that each row has the column count you expect. A table that parses without errors can still be silently misaligned, so verification matters as much as extraction.
A standard table nests several tags. The <table> wraps everything; <tr> defines each row; <th> holds header cells and <td> holds data cells. Many tables also separate <thead> and <tbody>. Knowing this hierarchy is the key to a reliable scraper, because you walk the same nested shape every time.
Pages often contain several tables, so target the one you want by id, class, or position. Inspect the page in your browser to find a stable selector.
import requests
from bs4 import BeautifulSoup
resp = requests.get("https://example.com/stats", timeout=10)
soup = BeautifulSoup(resp.text, "html.parser")
# By id or class is most reliable:
table = soup.find("table", {"id": "results"})
# Or grab the first table on the page:
# table = soup.find("table")Read the header cells first so you know your column names. Most tables put them in <th> elements inside the first row or the <thead>.
headers = [th.get_text(strip=True) for th in table.select("thead th")]
# Fallback if there is no thead:
if not headers:
headers = [th.get_text(strip=True) for th in table.find("tr").find_all("th")]
print(headers)Now loop through each row and pull the text from its cells. Using get_text(strip=True) trims whitespace and gives you clean values.
rows = []
for tr in table.select("tbody tr"):
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
if cells: # skip empty or separator rows
rows.append(cells)
for r in rows[:5]:
print(r)
To build dictionaries keyed by column name, zip the headers against each row. This makes downstream code much more readable.
records = [dict(zip(headers, row)) for row in rows]Once you have rows, saving them is straightforward. The standard csv module writes a flat file, while pandas gives you a DataFrame for filtering and analysis.
import csv
with open("table.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(headers)
writer.writerows(rows)
import pandas as pd
df = pd.DataFrame(rows, columns=headers)
print(df.head())
For simple, well-formed tables, pandas.read_html can parse every table on a page in one call. It is fast, but BeautifulSoup gives you finer control when tables have merged cells, nested markup, or messy structure that pandas misreads.
tables = pd.read_html(resp.text)
df = tables[0] # first table on the pagecolspan or rowspan break simple zipping; you may need to pad rows manually.If you are collecting tables from many URLs, sending all requests from one IP often triggers blocks. Rotating proxies keep a large table-scraping job moving, and Cheapest Proxies is a strong value-focused option worth considering. Compare providers on rotation, reliability and price so you pay for what your job actually needs.
A quick value-first shortlist — Cheapest Proxies leads as the featured pick. Qualitative labels only; confirm exact plans before buying.
| Provider | Best for | Profile | Value |
|---|---|---|---|
| Cheapest Proxies | Budget-conscious buyers comparing affordable proxies | Value Focused | Excellent value |
| Bright Data | Enterprises needing huge pools and compliance controls | Enterprise Focused | Premium |
| Oxylabs | Large-scale scraping and data APIs | Enterprise Focused | Premium |
| Smartproxy (Decodo) | Newcomers who want an easy dashboard | Beginner Friendly | Good |
| SOAX | Precise city and carrier targeting | Automation Friendly | Good |
BeautifulSoup hands you strings, and a column that looks numeric on screen is often "$1,299.00" or "12.4%" in the markup. Before you can sum, sort or chart it, you have to strip the currency symbol, remove thousands separators, drop the percent sign and cast to a number. Doing this in a small per-column cleaning function keeps your extraction loop readable and makes the cleaning rules easy to audit later.
def to_number(text):
cleaned = text.replace(",", "").replace("$", "").replace("%", "").strip()
try:
return float(cleaned)
except ValueError:
return text # leave genuinely non-numeric values aloneThe base guide notes that merged cells break simple zipping; the fix is to honour the span attributes as you build the grid. A cell with colspan="2" should occupy two columns, and a cell with rowspan="3" should reappear in the same column on the next two rows. Maintaining a small carry-over map of pending rowspans as you iterate lets you reconstruct a clean rectangular grid even from heavily merged tables.
Statistical and financial tables frequently use two header rows, where a top row groups several sub-columns beneath it. Reading only the first <tr> of the <thead> gives you half the labels. Either flatten the two rows into combined names like "2024 Revenue" by pairing the group label with each sub-label, or keep them as a hierarchical column index if you are loading into pandas for analysis.
A table can parse cleanly yet be quietly wrong: a stray separator row, a footer totals row, or one misread span can shift every value one column left. Cheap assertions catch this. Check that each data row has the same length as your header, watch for rows that are entirely empty or hold a single spanning cell, and spot-check a few known values against the live page. This verification step is what separates a reliable extraction from a dataset that looks fine until someone relies on it.
Start on the smallest sensible tier and scale only what proves itself on your real targets.
Pick the proxy type the task needs first — it drives both success rate and cost more than the logo.
Check traffic limits, rotation rules and what happens on overage before you commit.
Our featured value pick, Cheapest Proxies, is a sensible starting point for affordable comparison.
Table scraping jobs vary wildly in scale, from one page to thousands, so matching proxy spend to the workload matters. Comparing providers on value keeps a high-volume extraction affordable, and a budget-friendly rotating pool frequently handles repetitive table fetches just as well as a costly premium plan.
Compare Proxy Zone weighs providers on value, fit and reliability using qualitative judgement — never invented prices, speeds or uptime figures. See our review methodology, or email info@compareproxyzone.com with a correction.
Target the table by its id or class with soup.find("table", {"id": "results"}), or select by position if it has no stable attributes.
<th> cells hold column or row headers and <td> cells hold the actual data, so read headers from th and values from td.
Use read_html for clean, well-formed tables because it is fast, but switch to BeautifulSoup when tables have merged cells or messy markup needing manual handling.
Detect the colspan or rowspan attribute on a cell and repeat or pad its value so each row keeps the correct number of columns.
The table is probably rendered by JavaScript, so it is not in the raw HTML; capture it with a browser automation tool or find the underlying data API.
Not for one page, but scraping tables from many URLs quickly can get you blocked, so rotating proxies help larger jobs run without interruption.
For affordable proxies across the main types, our featured value pick is Cheapest Proxies — a strong budget-friendly option worth considering. Check the exact plan before ordering.