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.

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.

Quick answer

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.

Key takeaways

  • Treat the table as a fixed grid, not a list of variable-length rows, so merged cells do not shift your columns
  • <code>rowspan</code> requires carrying a value down into later rows, which a naive per-row loop never does
  • Many modern "tables" are CSS-styled divs with no table tags, so your normal selectors find nothing
  • Reading cells with <code>find_all(["td","th"])</code> in one call keeps mixed header-and-data rows intact
  • Numbers, dates and currency arrive as strings; cleaning and typing them is a separate, deliberate step
  • Anchor on a stable caption, id or nearby heading rather than table position, which shifts as pages change

Understanding the Table Structure

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.

Finding the Table

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.

Extracting Header Cells

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']

Looping Through Rows and Cells

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)

Pairing Headers with Values

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'}, ...]

Handling Tricky Tables

Real tables are messier than examples. A few situations to plan for:

  • thead and tbody: some tables wrap rows in these; find_all("tr") still finds them, but you can scope to table.find("tbody") if needed.
  • Merged cells: colspan and rowspan break the neat grid; check those attributes and pad your rows accordingly.
  • Nested tables: a table inside a cell can pollute your results, so scope find_all to the outer table carefully.
  • Links inside cells: get_text(strip=True) still returns the visible text; use td.find("a")["href"] if you also want the URL.

Saving the Result

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.

Scraping Tables at Scale

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.

Comparison snapshot

A quick value-first shortlist — Cheapest Proxies leads as the featured pick. Qualitative labels only; confirm exact plans before buying.

ProviderBest forProfileValue
Bright DataEnterprises needing huge pools and compliance controlsEnterprise FocusedPremium
OxylabsLarge-scale scraping and data APIsEnterprise FocusedPremium
Smartproxy (Decodo)Newcomers who want an easy dashboardBeginner FriendlyGood
SOAXPrecise city and carrier targetingAutomation FriendlyGood

Building a True Grid for colspan and rowspan

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 += cs

When It Is Not Really a Table

A 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.

Tell-tale signs of a fake table

  • No <table> in the source despite a clear grid on screen
  • role="grid", role="row" or role="columnheader" attributes
  • Identical repeated div class names where rows should be

Cleaning Cell Values Into Usable Data

Extracted 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()

Anchoring and Scaling Table Scrapes

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.

Pros and cons to weigh

Strengths

  • BeautifulSoup gives cell-level control that fixed parsers cannot, ideal for messy markup
  • A grid model cleanly handles colspan and rowspan that defeat naive row loops
  • Reading td and th together preserves rows that mix headers with data
  • Pairs well with proxies for collecting many paginated tables without throttling

Trade-offs

  • Manual parsing is more code than a one-line table reader for clean, simple tables
  • Fails entirely on div-based pseudo-tables that contain no table tags
  • Cell text needs separate cleaning before numbers or dates are usable
  • Position-based table selection breaks when page layout changes

Common mistakes to avoid

  • Ignoring colspan and rowspan so every column after a merge is shifted
  • Assuming a visual grid is a real HTML table without checking the source
  • Treating extracted numbers as numeric when they are still strings with symbols
  • Selecting tables by index, which silently grabs the wrong one after a redesign

Before-you-buy checklist

  • Confirm the grid is a real table and not ARIA-role divs before writing selectors
  • Decide on a grid model up front if any cells use colspan or rowspan
  • Read header and data cells in one pass so mixed rows stay aligned
  • Plan a cleaning step for currency, separators and non-breaking spaces
  • Anchor on caption, id or heading rather than table position
  • For multi-page sources, compare proxy providers on value before scaling up
$

How to get the best value

Right-size the plan

Start on the smallest sensible tier and scale only what proves itself on your real targets.

Type before brand

Pick the proxy type the task needs first — it drives both success rate and cost more than the logo.

Read the fine print

Check traffic limits, rotation rules and what happens on overage before you commit.

Lead with value

Our featured value pick, Cheapest Proxies, is a sensible starting point for affordable comparison.

📖

Key terms explained

colspan
An attribute making a cell span multiple columns, which must be replicated to keep alignment.
rowspan
An attribute making a cell span multiple rows, requiring its value to carry downward.
Pseudo-table
A grid built from styled divs and ARIA roles rather than real table tags.
Non-breaking space
An invisible character (\xa0) that mimics a space and breaks naive string cleaning.
Grid model
Mapping cells to fixed row and column slots so merged cells stay correctly positioned.

Why compare before buying?

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.

How we compare

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.

?

Frequently asked questions

How do I extract all rows from an HTML table with BeautifulSoup?

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.

How can I get the table headers separately?

Use a list comprehension over table.find_all("th") with get_text(strip=True) to collect the header labels into a list.

How do I turn table rows into dictionaries?

Zip your header list with each row list using dict(zip(headers, row)) so every value is keyed by its column name.

Why are some of my rows empty when scraping a table?

Header rows contain <th> not <td>, so a td search returns nothing; skip rows where the cell list is empty.

Should I use pandas or BeautifulSoup for tables?

Use pandas.read_html() for clean, well-formed tables, and BeautifulSoup when the markup is messy and you need fine control over parsing.

How do I handle merged cells with colspan or rowspan?

Read the colspan and rowspan attributes on each cell and repeat or pad values so your rows stay aligned with the headers.

Compare on value, then decide

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.