Knowledge Base
How to Extract Text from a Table Using Beautifulsoup
A step-by-step Python tutorial on turning HTML tables into structured data with BeautifulSoup, covering headers, rows, cells and common edge cases.
Knowledge Base
A step-by-step Python tutorial on turning HTML tables into structured data with BeautifulSoup, covering headers, rows, cells and common edge cases.
HTML tables hold an enormous amount of structured information, pricing grids, sports stats, financial figures and product specs. Turning that grid into clean rows you can work with in Python is a core scraping skill, and BeautifulSoup handles it well once you understand the table structure.
This tutorial shows how to extract text from a table: locating the table, reading header and body rows, pulling each cell, and assembling the result into lists or dictionaries you can save or analyse.
Loop over each <tr>, read its cells with find_all(["td","th"]), and call get_text(strip=True) on each. The hard part is not the loop but the irregular tables: merged cells from colspan and rowspan, rows that span sections, and grids that look tabular but are actually nested divs. Build a fixed-width grid and forward-fill spanned values to keep columns aligned.
A standard HTML table nests several tags. Knowing them tells you exactly what to loop over:
<table> wraps the whole thing.<tr> defines a table row.<th> holds a header cell, usually in the first row.<td> holds a normal data cell.Your job is to find the table, iterate its rows, and read the cells inside each row.
If there is only one table, find() is enough. When a page has several, target it by id, class, or position:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
table = soup.find("table", id="stats")
If you need a specific table among many, soup.find_all("table") returns them all and you can index the one you want.
Read the header row so you have column names. This makes the data far more usable than bare lists:
headers = [th.get_text(strip=True) for th in table.find_all("th")]
print(headers)
# ['Name', 'Country', 'Score']Iterate over each <tr>, then read its <td> cells. Stripping whitespace keeps the values clean:
rows = []
for tr in table.find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
if cells: # skip header rows with no td
rows.append(cells)
for row in rows:
print(row)
For more readable output, zip the headers with each row to build dictionaries:
data = [dict(zip(headers, row)) for row in rows]
# [{'Name': 'Ada', 'Country': 'UK', 'Score': '92'}, ...]Real tables are messier than examples. A few situations to plan for:
find_all("tr") still finds them, but you can scope to table.find("tbody") if needed.colspan and rowspan break the neat grid; check those attributes and pad your rows accordingly.find_all to the outer table carefully.get_text(strip=True) still returns the visible text; use td.find("a")["href"] if you also want the URL.Once rows are dictionaries, exporting to CSV is straightforward with the standard library:
import csv
with open("table.csv", "w", newline="") as f:
writer = csv.DictWriter(f, fieldnames=headers)
writer.writeheader()
writer.writerows(data)
For heavier analysis, pandas.read_html() can parse tables directly, but the manual BeautifulSoup approach gives you finer control over messy markup.
Pulling one table is quick, but data sites often hold many paginated tables and watch request volume closely. Proxies let you collect across many pages without tripping rate limits, and since cost and reliability vary, comparing providers on value first is smart. Cheapest Proxies is a strong value-focused option worth considering.
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 |
The base article warns about merged cells; here is the actual technique. A cell with colspan="2" occupies two columns, and one with rowspan="3" claims the same column in the next two rows. A row-by-row reader silently misaligns everything after the first merge. The reliable fix is a grid model: track which (row, column) slots are already filled by a span, and place each new cell into the next free slot, repeating its value across the columns or rows it spans.
from collections import defaultdict
occupied = defaultdict(dict)
for r, tr in enumerate(table.find_all("tr")):
c = 0
for cell in tr.find_all(["td", "th"]):
while occupied[r].get(c):
c += 1
text = cell.get_text(strip=True)
cs = int(cell.get("colspan", 1))
rs = int(cell.get("rowspan", 1))
for dr in range(rs):
for dc in range(cs):
occupied[r + dr][c + dc] = text
c += csA large share of scraping failures come from grids that look like tables but use <div> with CSS grid or flexbox and ARIA roles such as role="row" and role="cell". Your find_all("tr") returns nothing because there are no table tags at all. Inspect the markup first; if you see ARIA roles, target those instead, and if the layout is pure styling, fall back to the repeating structural pattern that defines each visual row.
<table> in the source despite a clear grid on screenrole="grid", role="row" or role="columnheader" attributesExtracted cells are always strings, and they are messy: thousands separators in numbers, currency symbols, footnote markers, trailing units and non-breaking spaces that look like ordinary spaces but are not. Plan a normalisation step after extraction rather than trusting raw cell text. Replace the non-breaking space, strip symbols, and convert types explicitly so a stray character does not break a later sum or sort.
raw = cell.get_text(strip=True).replace("\xa0", " ")
clean = raw.replace(",", "").replace("$", "").strip()Indexing tables by position is brittle because an added promo block or sidebar table shifts every index. Anchor instead on the table's <caption>, an id, or a heading immediately before it, then walk to the table. At volume, the constraint becomes fetching many paginated tables without tripping request limits, so comparing proxy providers on value before committing, including budget-focused picks like Cheapest Proxies, keeps large extraction jobs dependable without overspending.
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 parsing rewards a careful approach, the difference between a clean dataset and a broken one is often how you handle headers and merged cells. The same value-first mindset applies to gathering the pages: compare proxy providers before buying so large table-scraping jobs stay both affordable and dependable.
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.
Find the table, loop over table.find_all("tr"), and for each row read the cells with tr.find_all("td"), calling get_text(strip=True) on each.
Use a list comprehension over table.find_all("th") with get_text(strip=True) to collect the header labels into a list.
Zip your header list with each row list using dict(zip(headers, row)) so every value is keyed by its column name.
Header rows contain <th> not <td>, so a td search returns nothing; skip rows where the cell list is empty.
Use pandas.read_html() for clean, well-formed tables, and BeautifulSoup when the markup is messy and you need fine control over parsing.
Read the colspan and rowspan attributes on each cell and repeat or pad values so your rows stay aligned with the headers.
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.