Analysts and marketing-operations teams often hold HTML they need as data: a saved product listing, a competitor's public price page, a table in an exported report. BeautifulSoup is a Python library built for exactly that. It turns messy, real-world markup into a tree you can search with short, readable code, so the effort goes into the data instead of the parsing. This guide covers installation, the selection patterns you will use day to day, and how to pair BeautifulSoup with requests and a proxy for live pages.
Responsible use
Nothing is at stake while you parse files already on your machine. Fetching live pages is different: the site's robots.txt and Terms of Service apply, the data has to be public and permitted, requests need spacing to respect rate limits, and logins and bot protection are off limits. Teams that keep to this keep their jobs running, and the sites they rely on stay healthy.
Installing the library and a parser
You need BeautifulSoup itself plus a parser. html.parser ships with Python and does the job; lxml is faster and more tolerant of broken markup:
pip install beautifulsoup4 lxml requests
Turning a snippet into a soup object
Hand some HTML and a parser name to BeautifulSoup and you get a soup object back. Plain attribute access then reaches into the tree:
from bs4 import BeautifulSoup
html = """
<html>
<body>
<h1 class="title">Welcome</h1>
<p class="intro">Hello <a href="/more">world</a>.</p>
</body>
</html>
"""
soup = BeautifulSoup(html, "lxml")
print(soup.h1.text) # Welcome
print(soup.p.a["href"]) # /more
print(soup.title) # None (no title tag here)
Locating elements by tag and attribute
Most lookups use find for the first match or find_all for every match, filtered by tag name, by attributes or by both:
# first paragraph with class "intro"
intro = soup.find("p", class_="intro")
# every link on the page
for link in soup.find_all("a"):
print(link.get("href"), "->", link.text)
# filter by any attribute
button = soup.find("button", attrs={"data-role": "submit"})
One quirk to know: class is a reserved word in Python, so filtering by class uses class_, with a trailing underscore. Writing class_ is correct, not a typo.
CSS selectors for people who know CSS
Anyone on the team who writes CSS can skip the method calls above. select and select_one take ordinary stylesheet selectors, which often makes them the shortest route to the data:
# all items inside a list with id "results"
for item in soup.select("#results li.product"):
name = item.select_one(".name").get_text(strip=True)
price = item.select_one(".price").get_text(strip=True)
print(name, price)
The strip=True option on get_text removes leading and trailing whitespace, which means one less cleanup step in the spreadsheet.
Moving around the tree
Sometimes the value you want sits beside or inside another element, for example the cell next to a "Price" label. BeautifulSoup follows those relationships directly:
row = soup.find("td", string="Price")
value = row.find_next_sibling("td").text # the cell after "Price"
parent_div = row.parent # go up one level
children = list(parent_div.children) # immediate children
find_next_sibling, find_previous_sibling, parent, children and find_parent cover most of these moves. Reach for them when the element you need has no useful class of its own.
Live pages through a proxy
Learning on snippets is fine, but reports need live pages. Fetch them with requests, parse them with BeautifulSoup, and send the request through a proxy so that a day of repeated fetching does not get the office IP blocked. Datacenter proxies are quick and economical for straightforward sites, while residential proxies are the choice for sites that are stricter with data-centre traffic. The proxies page compares all four types.
import requests
from bs4 import BeautifulSoup
proxies = {
"http": "http://USER:PASS@HOST:PORT",
"https": "http://USER:PASS@HOST:PORT",
}
headers = {"User-Agent": "Mozilla/5.0 (compatible; MyScraper/1.0)"}
resp = requests.get(
"https://example.com/products",
headers=headers,
proxies=proxies,
timeout=20,
)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "lxml")
for card in soup.select("div.product-card"):
title = card.select_one("h2")
price = card.select_one(".price")
if title and price:
print(title.get_text(strip=True), price.get_text(strip=True))
The User-Agent in this example announces itself as a scraper, which is the transparent choice for a business collecting public data.
Habits for messy HTML
- Test the result of
findorselect_onebefore reading text from it. When nothing matched you getNone, and reading an attribute from that stops the script with an error. - When an attribute might be missing,
get("href")is safer than["href"]:gethands backNonewhere square brackets would raise an error. - Prefer
lxmlwhen pages are large or have unclosed tags; it is quicker and more forgiving. - If a class name appears all over the page, start the selector from a nearby parent with a stable id or data attribute.
A two-minute pre-flight check
Run the endpoint through our proxy checker, and open the my IP tool through the same proxy, before the first real job. If either shows a problem, you have just saved an afternoon of confusing debugging.
From parsed rows to a report
Most analysis ends in a spreadsheet or dashboard, so collect each parsed item as a dictionary with the same keys every time. A list of those dictionaries writes straight to CSV with Python's csv module, or loads into pandas for further work. Once find, select and tree navigation feel familiar, most pages become routine to parse. Add polite request habits and dependable proxies, and you have a maintainable base for regular reporting.