HTML tables

thead, tbody, and a list of rows.

A table is rows of cells. Skip the header row, then read <td> text. select("table#sales tbody tr") is more precise than every tr on the page.

Goal

Parse a city × units table into a list of lists.

Rows

html = """
<table id="sales">
  <thead>
    <tr><th>city</th><th>units</th></tr>
  </thead>
  <tbody>
    <tr><td>Nairobi</td><td>12</td></tr>
    <tr><td>Mombasa</td><td>9</td></tr>
    <tr><td>Kisumu</td><td>11</td></tr>
  </tbody>
</table>
"""
soup = BeautifulSoup(html, "html.parser")
rows = []
for tr in soup.select("#sales tbody tr"):
    rows.append([td.get_text(strip=True) for td in tr.find_all("td")])
print(rows)

Header names

html = """
<table>
  <tr><th>city</th><th>product</th><th>units</th></tr>
  <tr><td>Nairobi</td><td>A</td><td>12</td></tr>
  <tr><td>Kisumu</td><td>B</td><td>11</td></tr>
</table>
"""
soup = BeautifulSoup(html, "html.parser")
header = [th.get_text(strip=True) for th in soup.select("tr")[0].find_all("th")]
body = []
for tr in soup.select("tr")[1:]:
    body.append([td.get_text(strip=True) for td in tr.find_all("td")])
print(header)
print(body)
Pitfall

find_all("tr") also hits header rows. Either use tbody or skip index 0.