LESSON 159 · PYTHON TOOLS28 min

BeautifulSoup parse table: extract HTML rows into Python records

Select one HTML table, read its header and cells, and turn rows into records without confusing nested tables.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: BeautifulSoup parse table: extract HTML rows into Python records

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    A page has two tables and only one contains the price data we need. If we collect every row on the page, unrelated records join the result. Predict what the first run will print. Beautiful Soup parses an HTML document already supplied to it; it does not download the page. We use a fixed local string so you can reproduce every step and focus on selectors, headers, cell text, and row validation. BeautifulSoup parse table is a common next step after downloading an HTML page. Beautiful Soup parses the markup; it does not fetch the page for you. Start with a small local HTML string so selection and extraction can be tested

  2. 00:41

    without internet access. Install beautifulsoup4 for local Python, create BeautifulSoup(html, 'html.parser'), and select the specific table by id or another stable attribute. A broad `find_all('tr')` on the entire document may collect rows from unrelated tables. A page may contain layout tables, historical data, and the table you actually need. Parse one known HTML string with BeautifulSoup and select the target by an id or another stable attribute before reading rows. This prevents a global `find_all('tr')` from mixing unrelated records.

  3. 01:23

    It also makes the first failure obvious when the target table is absent. The broken run asks the whole soup object for every tr. It prints Old and Tea together. No exception warns us that the first row belongs to a different table. That kind of quiet data mixing is more dangerous than a syntax error because the output can look plausible. Before iterating, identify a stable marker for the table you actually need. Here we use id=prices, which is simple enough to inspect in the fixture. A table row can contain th header cells, td data cells, or both. Read the header from the chosen table, then map each subsequent row to columns.

  4. 02:06

    `get_text(' ', strip=True)` inserts spaces between nested text nodes and trims surrounding whitespace. It does not convert numbers, dates, or currency automatically. If a row has fewer cells than headers, do not silently zip away missing columns. If it has extra cells, decide whether to reject or handle them explicitly. This guide rejects nonmatching data rows for a predictable teaching contract. Beautiful Soup accepts HTML supplied by your program; it does not download a URL. Keeping the fixture in this lesson local avoids network variability.

  5. 02:46

    On a real site, inspect the returned HTML, not only the rendered browser view: JavaScript may add rows after the static response, or the data may be paginated. If the HTML lacks the rows, another data source is needed. Now select #prices first and search inside it. We see only its header and data row. Read th for column names and td for values; get_text with a space separator and strip=True handles extra whitespace around nested text. It still returns strings, so a value such as 2.50 is not yet a float. Decide where numeric conversion belongs after structure is valid.

  6. 03:28

    On a real site, inspect the returned HTML because JavaScript may add rows later and a static parser may see none. Use `table.select('tr')` only after selecting the correct table; a nested table can still yield descendant rows. For a straightforward flat table fixture, this is enough. For an uncontrolled site, inspect direct children or identify a specific tbody, and examine actual markup in developer tools. Never assume every site provides semantic table markup: some use div-based grids, JavaScript rendering, or pagination. A static parser cannot invent rows that are not present in the HTML response.

  7. 04:12

    A header row usually uses th cells, while data rows use td. Read those separately and compare the number of data cells with the number of headers. `get_text(' ', strip=True)` joins nested text with spaces and removes surrounding whitespace, preserving a usable string. It does not infer numeric types; the price 2.50 is still text at this stage. The corrected function scopes selection to the requested table, checks for a header, reads direct child th and td cells, and compares row widths before zipping them into dictionaries. This rejects a row that would otherwise lose a column silently.

  8. 04:52

    It also checks duplicate or empty headers because either would make dictionary keys ambiguous. These choices form one clear extraction contract for a flat table. A nested or irregular table may need different traversal rules. The browser lesson runs Beautiful Soup on bundled HTML and checks a function that returns dictionaries. It does not scrape a live site, so outputs are deterministic and there is no site- specific crawling policy hidden in the example. Compare the parsed text with the source and confirm that header names are exactly what you expect before converting cells. For a real site, obtain permission or follow its terms, use reasonable request

  9. 05:35

    rates, and prefer a documented data API when available. The offline web crawler project covers page discovery; this lesson focuses narrowly on extracting a table from one HTML document. The example uses direct child cells. This avoids accidentally taking a cell from a nested table when one appears inside another cell. For an uncontrolled document, inspect tbody and table structure before assuming a simple direct row selection. The browser task rejects rows with missing or extra cells instead of silently truncating them with zip. The final run supplies a ragged row and gets ValueError.

  10. 06:18

    Change the fixture to add a second complete row and predict the record list. For live acquisition, this parsing function belongs after a separate HTTP client with timeouts, modest request rates, and attention to site terms. A documented API is often better than scraping unstable markup. This lesson is about one HTML document in memory; the linked offline crawler lesson covers navigation and page discovery without hitting third-party websites. Once a flat table's headers and cells are validated, dict(zip(headers, values)) creates a record for each row. Keep the original string values until a separate conversion stage can report

  11. 07:01

    invalid prices or dates with row context. An empty table, missing header, duplicate header, or ragged row should have an explicit policy instead of producing a misleading partial result. For a live scraping workflow, add a responsible acquisition layer outside this function: an HTTP client, timeouts, rate limits, and the site's usage rules. Prefer a documented API when one exists. This extraction function is deliberately limited to an HTML document already in memory, which makes its tests quick and stable. Compare the returned records to the original markup after any site redesign.

Understand the concept

BeautifulSoup parse table is a common next step after downloading an HTML page. Beautiful Soup parses the markup; it does not fetch the page for you. Start with a small local HTML string so selection and extraction can be tested without internet access. In your local Python environment, run `python -m pip install beautifulsoup4`, create BeautifulSoup(html, 'html.parser'), and select the specific table by id or another stable attribute. A broad `find_all('tr')` on the entire document may collect rows from unrelated tables.

A table row can contain th header cells, td data cells, or both. Read the header from the chosen table, then map each subsequent row to columns. `get_text(' ', strip=True)` inserts spaces between nested text nodes and trims surrounding whitespace. It does not convert numbers, dates, or currency automatically. If a row has fewer cells than headers, do not silently zip away missing columns. If it has extra cells, decide whether to reject or handle them explicitly. This guide rejects nonmatching data rows for a predictable teaching contract.

Use `table.select('tr')` only after selecting the correct table; a nested table can still yield descendant rows. For a straightforward flat table fixture, this is enough. For an uncontrolled site, inspect direct children or identify a specific tbody, and examine actual markup in developer tools. Never assume every site provides semantic table markup: some use div-based grids, JavaScript rendering, or pagination. A static parser cannot invent rows that are not present in the HTML response.

The browser lesson runs Beautiful Soup on bundled HTML and checks a function that returns dictionaries. It does not scrape a live site, so outputs are deterministic and there is no site-specific crawling policy hidden in the example. Compare the parsed text with the source and confirm that header names are exactly what you expect before converting cells. For a real site, obtain permission or follow its terms, use reasonable request rates, and prefer a documented data API when available. The offline web crawler project covers page discovery; this lesson focuses narrowly on extracting a table from one HTML document.

  • BeautifulSoup parses provided HTML
  • Select a specific table
  • th and td have different roles
  • Validate row widths before dict(zip(...))

See it step by step

Read the code, predict the output, then compare it with the result.

01. Select the requested table

PYTHON
from bs4 import BeautifulSoup
html = '<table id="old"><tr><td>ignore</td></tr></table><table id="prices"><tr><td>Tea</td></tr></table>'
soup = BeautifulSoup(html, 'html.parser')
print(soup.select_one('#prices').get_text(' ', strip=True))
EXPECTED OUTPUT
Tea

A CSS selector for one table avoids mixing in an unrelated table's rows.

02. Read header and data text

PYTHON
from bs4 import BeautifulSoup
html = '<table><tr><th>item</th><th>price</th></tr><tr><td>Tea</td><td>2.50</td></tr></table>'
rows = BeautifulSoup(html, 'html.parser').select('tr')
print([c.get_text(' ',strip=True) for c in rows[0].find_all('th')])
print([c.get_text(' ',strip=True) for c in rows[1].find_all('td')])
EXPECTED OUTPUT
['item', 'price']
['Tea', '2.50']

Cell text stays as strings; numeric conversion is a separate step.

03. Detect a row-width mismatch

PYTHON
headers = ['item','price']
cells = ['Tea']
print(len(headers) == len(cells))
EXPECTED OUTPUT
False

Rejecting a ragged row is safer than quietly dropping a column with zip().

Run the complete example locally

For local Python, install beautifulsoup4 with python -m pip install beautifulsoup4. The examples here use a fixed HTML string and can also run in the browser console after the package loads; they make no network request.

PYTHON
from bs4 import BeautifulSoup
html = '<table id="prices"><tr><th>item</th><th>price</th></tr><tr><td>Tea</td><td>2.50</td></tr></table>'
soup = BeautifulSoup(html, 'html.parser')
table = soup.select_one('#prices')
for row in table.select('tr'):
    print([cell.get_text(' ', strip=True) for cell in row.find_all(['th','td'], recursive=False)])
EXPECTED OUTPUT
['item', 'price']
['Tea', '2.50']

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Choose the right table

A page may contain layout tables, historical data, and the table you actually need. Run `python -m pip install beautifulsoup4` in your local Python environment, then parse one known HTML string with BeautifulSoup and select the target by an id or another stable attribute before reading rows. This prevents a global `find_all('tr')` from mixing unrelated records. It also makes the first failure obvious when the target table is absent.

Beautiful Soup accepts HTML supplied by your program; it does not download a URL. Keeping the fixture in this lesson local avoids network variability. On a real site, inspect the returned HTML, not only the rendered browser view: JavaScript may add rows after the static response, or the data may be paginated. If the HTML lacks the rows, another data source is needed.

PYTHON
from bs4 import BeautifulSoup
html = '<table id="other"><tr><td>Old</td></tr></table><table id="prices"><tr><td>Tea</td></tr></table>'
soup = BeautifulSoup(html, 'html.parser')
print(soup.select_one('#prices').get_text(' ', strip=True))
EXPECTED OUTPUT
Tea
Follow the reasoning
  1. Parse a fixed HTML fixture with html.parser.
  2. Select only the table with id=prices.
02 / 03

Read headers and cells

A header row usually uses th cells, while data rows use td. Read those separately and compare the number of data cells with the number of headers. `get_text(' ', strip=True)` joins nested text with spaces and removes surrounding whitespace, preserving a usable string. It does not infer numeric types; the price 2.50 is still text at this stage.

The example uses direct child cells. This avoids accidentally taking a cell from a nested table when one appears inside another cell. For an uncontrolled document, inspect tbody and table structure before assuming a simple direct row selection. The browser task rejects rows with missing or extra cells instead of silently truncating them with zip.

PYTHON
from bs4 import BeautifulSoup
html = '<table><tr><th>item</th><th>price</th></tr><tr><td>Tea</td><td>2.50</td></tr></table>'
rows = BeautifulSoup(html, 'html.parser').select('tr')
headers = [c.get_text(' ',strip=True) for c in rows[0].find_all('th',recursive=False)]
values = [c.get_text(' ',strip=True) for c in rows[1].find_all('td',recursive=False)]
print(headers, values)
EXPECTED OUTPUT
['item', 'price'] ['Tea', '2.50']
Follow the reasoning
  1. Extract direct th labels and direct td values.
  2. Confirm both lists have the same length before mapping.
03 / 03

Build records and test malformed rows

Once a flat table's headers and cells are validated, dict(zip(headers, values)) creates a record for each row. Keep the original string values until a separate conversion stage can report invalid prices or dates with row context. An empty table, missing header, duplicate header, or ragged row should have an explicit policy instead of producing a misleading partial result.

For a live scraping workflow, add a responsible acquisition layer outside this function: an HTTP client, timeouts, rate limits, and the site's usage rules. Prefer a documented API when one exists. This extraction function is deliberately limited to an HTML document already in memory, which makes its tests quick and stable. Compare the returned records to the original markup after any site redesign.

PYTHON
headers = ['item','price']
rows = [['Tea','2.50'],['Coffee','3.00']]
print([dict(zip(headers,row)) for row in rows])
EXPECTED OUTPUT
[{'item': 'Tea', 'price': '2.50'}, {'item': 'Coffee', 'price': '3.00'}]
Follow the reasoning
  1. Map equal-width rows into dictionaries.
  2. Reject a malformed row before handing records to downstream code.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnMake table_records(html, table_id) select exactly that table, require a header row, and reject data rows whose direct td cell count differs from the header count. Return text values in dictionaries. The prepared call should print [{'item': 'Tea', 'price': '2.50'}].
Need a hint?

Check table is not None and rows exist. Use find_all(..., recursive=False) for direct cells, then compare lengths before dict(zip(headers, values)).

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Does Beautiful Soup download the page?

    02.Why choose a table by id first?

    03.What should happen when a data row has fewer cells than headers?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Selecting every row on the page

    COMMON MISTAKE
    soup.find_all('tr')
    FIX
    Select the intended table first, then its rows.

    What happens: Rows from a second table mix into the result.
    Scope a query to the table that carries the data you need.

    Silently truncating ragged rows

    COMMON MISTAKE
    dict(zip(headers, cells))
    FIX
    Compare header and cell counts before zipping.

    What happens: A missing cell disappears from the record without warning.
    A predictable extraction contract makes later analysis reliable.

    Browse this lesson’s problem fixes

    Sources and further reading