Beautiful Soup is a popular Python library for reading, searching, and extracting information from HTML and XML. It turns messy markup into a navigable tree, making Beautiful Soup useful for web scraping, content analysis, testing, and data cleanup.
Its main strengths are a beginner-friendly API, flexible search methods, CSS selector support, and the ability to work with several parser backends. If you need to collect HTML data without manually processing raw tags, Beautiful Soup is one of the simplest places to start.
Setting up Beautiful Soup
Before parsing HTML, you need Python and the Beautiful Soup package installed. Open a terminal or command prompt and run:
pip install beautifulsoup4
The package name is beautifulsoup4, while the class you normally import is BeautifulSoup:
from bs4 import BeautifulSoup
That from bs4 import BeautifulSoup line will appear throughout this guide because it is the standard starting point for most scripts.
For basic work, Python's built-in html.parser is enough:
from bs4 import BeautifulSoup
html = "<html><body><h1>Hello</h1></body></html>"
soup = BeautifulSoup(html, "html.parser")
print(soup.h1.get_text())
The soup variable is your Beautiful Soup object. Instead of treating the page as one long string, you can now search its HTML structure, move between parent and child nodes, read attributes, and extract text.
Choosing the right parser: html.parser vs. lxml vs. html5lib
Beautiful Soup is not an HTML parser itself but offers an easy-to-use API built upon various parser libraries. The selected parser will affect performance, requirements, and how errors in markup will be corrected. Every HTML parser follows slightly different recovery rules as well.
html.parser
Included with Python
No extra dependency
Small scripts and simple pages
lxml
pip install lxml
Fast and efficient
Larger scraping workloads
html5lib
pip install html5lib
Browser-like error handling
Very broken or inconsistent markup
To use lxml:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
To use html5lib:
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html5lib")
For XML, install lxml and pass "xml" or "lxml-xml" as the parser. That distinction matters because XML rules differ from HTML rules, especially around self-closing tags and case sensitivity.
There is not one definitive winner. html.parser is convenient, lxml works great for performance requirements, and html5lib is useful for recovery in the HTML5 style.
The key is consistency; there may be different tree results from different parsers from corrupt HTML markup. In cases where your scraper relies on certain paths within the page, use an explicit parser rather than the default one.
Parsing HTML documents
With your environment configured, the general process will be straightforward. Namely, to feed content in, create the BeautifulSoup object, find the pieces you want, and turn them into useful data. The process holds true for input from files, strings, or even websites on the internet.
Loading an HTML file or string
The simplest example begins with an in-memory string:
from bs4 import BeautifulSoup
html = """
<html>
<body>
<h1>Proxy Guide</h1>
<p class="summary">A short introduction.</p>
</body>
</html>
"""
soup = BeautifulSoup(html, "html.parser")
print(soup.h1.get_text(strip=True))
Beautiful Soup reads the HTML document and builds a tree. You can then access the first matching HTML tag as an attribute, as shown with soup.h1.
For a local file, open it and pass the file handle to Beautiful Soup:
from bs4 import BeautifulSoup
with open("page.html", "r", encoding="utf-8") as file:
soup = BeautifulSoup(file, "html.parser")
print(soup.title.get_text(strip=True))
This works especially well after you've downloaded pages and want to separate the collection process from the data parsing. Using files you've saved will also help with debugging because the input doesn't change each time.
For parsing a URL, combine Beautiful Soup with Requests:
import requests
from bs4 import BeautifulSoup
url = "https://example.com/"
response = requests.get(url, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.content, "html.parser")
print(soup.title.get_text(strip=True))
Requests fetches the page; Beautiful Soup handles parsing HTML after the response arrives. raise_for_status() stops the script on unsuccessful HTTP responses, while timeout prevents a request from waiting forever for network activity.
One thing to keep in mind is that Requests only downloads the content sent back by the server. Sometimes if the necessary content is generated dynamically via JavaScript after loading the HTML document, the content might not be available in the document itself.
Here, it will be better to analyze the response before jumping into any conclusion about your selector.
Navigating the parse tree
After parsing the HTML code, you can traverse the resulting tree in many ways. The fastest one is to directly access HTML tags:
title = soup.h1
first_paragraph = soup.p
It will return the first found HTML tag. It works well for known web pages, but most scrapers require more specific searches.
The .find() method returns the first match:
article = soup.find("article")
You can filter by attributes:
price = soup.find("span", class_="price")
product = soup.find("div", id="product-42")
Because class is a Python keyword, Beautiful Soup uses class_ in method arguments.
Use .find_all() when you need every matching element:
links = soup.find_all("a")
for link in links:
print(link.get_text(strip=True))
find_all() returns a list-like result that you can iterate over. You can also pass limits and attribute filters:
cards = soup.find_all("div", class_="product-card", limit=10)
CSS selectors are another powerful option. Use .select_one() for the first match and .select() for all matches:
main_price = soup.select_one("article.product .price")
items = soup.select("ul.results > li.item")
CSS selectors are especially convenient when the HTML structure is already familiar from browser developer tools. Classes use ., IDs use #, and descendant relationships can be expressed much like they are in a stylesheet.
Tree navigation is possible using Beautiful Soup properties like .parent, .children, .next_sibling, and .previous_sibling. For example:
heading = soup.find("h2")
section = heading.parent
for child in section.children:
print(child)
Use these relationships when the location of the target depends on nearby HTML elements rather than a unique class or ID.
Extracting data from HTML
Finding a node is only half the job. Next, you turn it into clean HTML data.
For visible text, use .get_text():
headline = soup.find("h1")
if headline:
text = headline.get_text(" ", strip=True)
print(text)
The separator " " keeps text from nested tags from running together, while strip=True removes extra whitespace.
Attributes work like a dictionary:
link = soup.find("a", class_="download")
if link:
href = link.get("href")
print(href)
Using .get() is safer than indexing with link["href"] because it returns None when the attribute is missing instead of raising a KeyError.
You can extract several fields into structured parsed data:
cards = soup.select(".product-card")
products = []
for card in cards:
name_tag = card.select_one(".name")
price_tag = card.select_one(".price")
link_tag = card.select_one("a")
products.append({
"name": name_tag.get_text(strip=True) if name_tag else None,
"price": price_tag.get_text(strip=True) if price_tag else None,
"url": link_tag.get("href") if link_tag else None,
})
print(products)
Defensive programming is important due to the evolution of websites. Just because the HTML tag is missing does not mean that the program needs to fail for everything. First, test if there is a match and then call the methods.
Malformed pages create a similar challenge. Beautiful Soup will try to build a workable tree, but the result depends on the chosen parser.
If extracted HTML data suddenly looks wrong, print or save the relevant section of the tree and compare it with the source response.
Practical examples
Understanding each technique separately is helpful, but understanding the process gets more obvious once you use them in combination. The following list provides some common tasks: extraction of product data, fixing bad markup, and working with tables, pictures, and lists.
Scraping data from a webpage
Assume a page contains repeated product cards like this:
<article class="product">
<h2 class="name">Residential Proxy Plan</h2>
<span class="price">$10</span>
<a href="/plans/residential">View plan</a>
</article>
A basic scraper can fetch the page, locate each card, and collect fields:
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
url = "https://example.com/products"
response = requests.get(url, timeout=15)
response.raise_for_status()
soup = BeautifulSoup(response.content, "lxml")
products = []
for card in soup.select("article.product"):
name = card.select_one(".name")
price = card.select_one(".price")
link = card.select_one("a[href]")
products.append({
"name": name.get_text(strip=True) if name else None,
"price": price.get_text(strip=True) if price else None,
"url": urljoin(url, link["href"]) if link else None,
})
for product in products:
print(product)
There are a few good habits packed into this example. First, the code searches within each product card rather than repeatedly scanning the complete HTML document.
Second, it handles missing components. Third, urljoin() converts relative links into absolute URLs.
From here, you could save the parsed data to CSV, JSON, a database, or another application. The extraction layer should stay separate from storage when possible; it makes the code easier to test and update.
For real websites, selectors can change. Prefer stable attributes that describe meaning, such as a product ID or data-* attribute, over long chains of layout classes that may be redesigned frequently.
Handling malformed HTML
Real pages are not always clean. Consider this broken snippet:
<div class="card">
<h2>Example product
<p>Available now
</div>
The <h2> and <p> tags are not closed properly. Beautiful Soup can still create a usable tree:
from bs4 import BeautifulSoup
broken_html = """
<div class="card">
<h2>Example product
<p>Available now
</div>
"""
soup = BeautifulSoup(broken_html, "html5lib")
print(soup.prettify())
print(soup.find("h2").get_text(" ", strip=True))
Here, html5lib applies HTML5-style parsing rules and inserts or rearranges nodes as needed. lxml and html.parser may produce a different tree for the same malformed input.
That does not mean one result is always correct and the others are wrong. Broken markup can be ambiguous.
When accuracy matters, test the parsers against representative pages and choose the one that produces the HTML structure your extraction logic expects.
You can also clean unwanted tags before extracting text:
for tag in soup(["script", "style", "noscript"]):
tag.decompose()
clean_text = soup.get_text(" ", strip=True)
.decompose() removes the selected nodes from the tree. This can be useful when converting a page into readable text or preparing content for downstream data parsing.
Extracting tables, images, and lists
Tables frequently lend themselves to row and column arrangements. Assume that you would like to extract all cells from a pricing table:
table = soup.find("table", id="pricing")
rows = []
if table:
for tr in table.find_all("tr"):
cells = tr.find_all(["th", "td"])
row = [cell.get_text(" ", strip=True) for cell in cells]
if row:
rows.append(row)
print(rows)
This produces a list of lists. If the first row contains headers, you can later convert it into dicts or a pandas DataFrame.
Images are mostly based on attributes:
images = []
for img in soup.find_all("img"):
images.append({
"src": img.get("src"),
"alt": img.get("alt", ""),
})
It should be noted that src can be relative, lazy-loaded images use attributes like data-src, and responsive sites use srcset. Check the HTML tag to determine the correct attribute containing the image URL.
Lists are even simpler:
features = [
li.get_text(" ", strip=True)
for li in soup.select("ul.features > li")
]
print(features)
The main point here is finding the repetitive pattern within the HTML code and extracting the fields from there. It is more effective than searching separately on the entire page for each value.
Advanced parsing techniques
After becoming familiar with the basics, you can narrow your searches down and save yourself unnecessary work.
Advanced use of Beautiful Soup typically involves restricting the search scope, combining the search criteria, or integrating the parser properly into the request layer.
Handling nested structures
Nested page elements may lead to writing brittle CSS selectors like:
soup.select("body > main > div > div > section > div > article > h2")
It could be successful now and unsuccessful after making some minor changes to its layout. A much better solution would be to find an appropriate container first and then search within it:
catalog = soup.find("section", id="catalog")
if catalog:
for card in catalog.select(".product"):
name = card.select_one(".name")
if name:
print(name.get_text(strip=True))
You can also search recursively with .find_all() or set recursive=False when you only want direct children:
menu = soup.find("ul", class_="main-menu")
if menu:
top_level_items = menu.find_all("li", recursive=False)
This is useful when nested lists contain the same HTML elements at several levels.
Another option is to move through relationships:
label = soup.find("span", string="Price")
value = label.find_next("span") if label else None
This can be done if the page contains labeled values but there is no unique class on the value alone. It is important, however, to exercise caution with relational reasoning as the insertion of one additional item can lead to different results.
Using regular expressions in searches
Beautiful Soup can combine searches with Python’s re module. This is useful when a tag name, attribute, or text follows a pattern rather than one exact value.
For example, find headings from h1 to h3:
import re
headings = soup.find_all(re.compile(r"^h[1-3]$"))
Find links whose URLs begin with /products/:
product_links = soup.find_all(
"a",
href=re.compile(r"^/products/")
)
Or match text:
price_text = soup.find_all(
string=re.compile(r"\$\d+(?:\.\d{2})?")
)
Regex works best as a targeted filter. Avoid turning the entire HTML document into one giant regular-expression problem.
Beautiful Soup already understands nested markup, attributes, and tree relationships, so use regex only for the parts that are genuinely pattern-based.
Combining Beautiful Soup with Requests
For live web scraping, Requests and BeautifulSoup make a good combination. Requests deals with the HTTP; BeautifulSoup deals with the returned markup.
Here is an example of how a generic pattern may look:
import requests
from bs4 import BeautifulSoup
def fetch_soup(url):
headers = {
"User-Agent": "ExampleResearchBot/1.0"
}
response = requests.get(
url,
headers=headers,
timeout=(5, 20),
)
response.raise_for_status()
return BeautifulSoup(response.content, "lxml")
soup = fetch_soup("https://example.com/")
print(soup.title.get_text(strip=True))
A tuple timeout lets you set separate connection and read values. For larger projects, a requests.Session() can also reuse connections and persist cookies.
In case there is a need to make several requests with permission, one may use a proxy server via the proxies parameter in the Requests module.
Providers like MarsProxies, for instance, offer residential proxies and other proxy types you can use when making requests in Python. Be mindful of your request pace and abide by the target website policy.
A sample of a simple proxy setup:
proxies = {
"http": "http://username:password@host:port",
"https": "http://username:password@host:port",
}
response = requests.get(
"https://example.com/",
proxies=proxies,
timeout=15,
)
The request layer and parsing layer should remain separate. If the server returns a login page, CAPTCHA prompt, error response, or JavaScript shell, no amount of selector tweaking will recover HTML data that was never in the response.
Best practices and optimization
Beautiful Soup is simple, but there are design decisions which can become important as scripts get more complicated.
Start by making a deliberate choice of a parser. In case of performance-critical projects, try lxml. In case the input markup is badly formed, compare html5lib with other parsers. Use the same parser in development and in production for consistent results.
Afterward, limit your searches. Once you find the parent container, perform further searches within it using .find()or .select()calls. This way, searching through a limited tree structure is easier and potentially faster than looking throughout the entire HTML file for each field.
Pick selectors that will stand up to a redesign. ID’s, semantic classes, data-* attribute, and meaningful relations are generally more robust than complex cascading selectors that are related to layout design.
Also, one should not depend on the positioning of an HTML tag unless positioning itself represents a semantical part of a web page.
Deal with missing values directly. This approach:
price = card.select_one(".price")
value = price.get_text(strip=True) if price else None
is better than assuming all cards are complete. You can choose to log incomplete cards, skip them, or set them to None.
In troubleshooting, examine the real response. Ensure to save the response.text, print a part of soup.prettify() and verify that you have your target prior to altering the selectors. Issues may arise from class name alteration, consent pages, HTTP errors, regional differences, or even JavaScript-loaded data.
For very large pages, consider parsing only what you need with SoupStrainer:
from bs4 import BeautifulSoup, SoupStrainer
only_articles = SoupStrainer("article")
soup = BeautifulSoup(
html,
"lxml",
parse_only=only_articles
)
This will help in saving unnecessary processing of the parse if the required information is restricted to a particular portion of the webpage.
Try to keep all four stages as separate stages in your parsing process. This includes retrieving the source, building the tree structure, extracting the fields, normalizing them, and finally storing the parsed information.
Also, scrape responsibly. Make sure you know what your site’s robots.txt file says about scraping, as well as its terms of service and any API or data access restrictions related to your use case. A robots.txt file gives guidance about how the site prefers to be crawled, but it is not a full description of legal permission.
If the website offers a suitable API, it is likely to be more robust than any attempt to parse HTML pages because web pages are meant for users and can be altered at any time, while APIs are created for software.
Conclusion
Beautiful Soup provides developers of Python with a convenient way of transforming the markup into a searchable tree and selecting useful fragments from it.
It includes fetching or loading the HTML file, choosing the appropriate parser, creating the soup, finding elements of HTML using tags searching or CSS selectors, and cleaning up the obtained output to get structured information.
To get reliable output, concentrate on stable selectors, robust handling of missing fields, proper choice of parsers, and proper division between network requests and data parsing processes.
Apply regular expressions wherever possible, limit your search to specific containers, and analyze the source response if the output is strange.
The next best thing is to work on a sample project with permission to use. Select one page at first, pick some fields, write down the output, and then move on to more complex things, like pagination, exception handling, sessions, and proxies if needed.
Once you master this workflow, Beautiful Soup will allow you to solve various HTML data extraction problems without complicating the code.