BeautifulSoup Web Scraping Tutorial
To scrape a website with Beautiful Soup, fetch the page with Requests, call raise_for_status(), parse it with BeautifulSoup(html, "lxml"), then loop over the repeating element: soup.find_all("div", class_="country") or the CSS equivalent soup.select("div.country"). Inside each card, find() or select_one() picks one field and get_text(strip=True) reads it; collect the fields into dicts and write them with csv.DictWriter. The 30-line script below pulls 250 rows of four fields from a practice page, with both methods giving identical rows. Beautiful Soup does not download pages or run JavaScript. ProxyField residential proxies go into the Requests call that feeds Beautiful Soup, from $1.95/GB; start with the Easy Trial: 1 GB for $2.95.
- residential IPs
- 412M+
- from, traffic never expires
- $1.95/GB
- Easy Trial
- 1 GB for $2.95
On this page 14 sections
- What Beautiful Soup does, and what it doesn't
- Install
- The full scraper: find_all() and select() on the same cards
- Choose a parser
- find() and find_all()
- CSS selectors: select() and select_one()
- Extract text and attributes
- Navigate the tree
- Scrape a table into rows
- XPath: use lxml next to Beautiful Soup
- Fetch through a proxy
- Common errors and what they mean
- Beautiful Soup vs Scrapy vs Selenium
- BeautifulSoup Web Scraping Tutorial FAQ
What Beautiful Soup does, and what it doesn't
Beautiful Soup (the beautifulsoup4 package, version 4.15.0, MIT license, per PyPI on 2026-10-23) parses HTML and XML into Python objects and gives you ways to search and walk them. Its documentation is the reference for every method below.
| Beautiful Soup does | Beautiful Soup does not |
|---|---|
| Parse HTML, including broken markup, into a tree | Download pages (use Requests or httpx) |
| Find elements by tag, class, id, attribute or text | Run JavaScript or click buttons (use Playwright or Selenium) |
| Select elements with CSS selectors (through Soup Sieve) | Evaluate XPath (use lxml) |
| Read text and attributes, walk to parents and siblings | Crawl, queue or retry (use Scrapy, or your own loop) |
| Fix encodings and output clean HTML | Rotate IPs (send the request through a proxy) |
Install
pip install beautifulsoup4 lxml requestsVersions on PyPI on 2026-10-27: beautifulsoup4 4.15.0 (MIT), lxml 6.1.3 (BSD-3-Clause), soupsieve 3.0.4 (MIT, installed with Beautiful Soup), requests 2.34.2 (Apache-2.0).
The full scraper: find_all() and select() on the same cards
The target is a practice sandbox: 250 countries, each a card like this:
<div class="col-md-4 country">
<h3 class="country-name"><i class="flag-icon flag-icon-ad"></i> Andorra</h3>
<div class="country-info">
<strong>Capital:</strong> <span class="country-capital">Andorra la Vella</span><br/>
<strong>Population:</strong> <span class="country-population">84000</span><br/>
<strong>Area (km<sup>2</sup>):</strong> <span class="country-area">468.0</span><br/>
</div>
</div>The script loops over every card twice, once with find_all() and once with CSS selectors, and writes the rows to CSV:
import csv
import requests
from bs4 import BeautifulSoup
url = "https://www.scrapethissite.com/pages/simple/"
response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=30)
response.raise_for_status() # stop on 403, 404 or 5xx instead of parsing an error page
soup = BeautifulSoup(response.text, "lxml")
def text(el):
return el.get_text(" ", strip=True) if el else None # None instead of AttributeError
# 1. find_all() for the cards, find() for each field
rows_find = []
for card in soup.find_all("div", class_="country"):
rows_find.append({
"name": text(card.find("h3", class_="country-name")),
"capital": text(card.find("span", class_="country-capital")),
"population": text(card.find("span", class_="country-population")),
"area_km2": text(card.find("span", class_="country-area")),
})
# 2. the same fields with CSS selectors
rows_css = []
for card in soup.select("div.country"):
rows_css.append({
"name": text(card.select_one("h3.country-name")),
"capital": text(card.select_one("span.country-capital")),
"population": text(card.select_one("span.country-population")),
"area_km2": text(card.select_one("span.country-area")),
})
print(len(rows_find), rows_find == rows_css) # 250 True
print(rows_find[0])
with open("countries.csv", "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=rows_find[0].keys())
writer.writeheader()
writer.writerows(rows_find)What it printed when we ran it on 2026-10-27 (Python 3, Beautiful Soup 4.15.0):
250 True
{'name': 'Andorra', 'capital': 'Andorra la Vella', 'population': '84000', 'area_km2': '468.0'}and countries.csv starts:
name,capital,population,area_km2
Andorra,Andorra la Vella,84000,468.0
United Arab Emirates,Abu Dhabi,4975593,82880.0Why each line is there:
raise_for_status()turns a blocked or missing page into anHTTPError. Without it, a 403 page parses into a soup with no cards and the script writes an empty file without complaint.class_="country"matches a tag whose class list containscountry, socol-md-4 countrymatches; the underscore is there becauseclassis a Python keyword.- The
text()guard returnsNonefor a missing field instead of raisingAttributeError: 'NoneType' object has no attribute 'get_text', so one odd card doesn't stop 249 good ones. get_text(" ", strip=True)drops the whitespace around "Andorra" that the flag icon leaves.
Both methods return the same 250 rows. Pick the one you read more easily: find_all() takes Python arguments (functions, regexes, limit), select() takes the selector you already tested in DevTools.
Choose a parser
The second argument to BeautifulSoup() picks the parser. They differ in speed and in how they repair broken HTML, so the same messy page can produce slightly different trees.
| Parser | How to call it | Speed | Notes |
|---|---|---|---|
html.parser | BeautifulSoup( | Moderate | Built into Python, nothing to install |
| lxml | BeautifulSoup( | Fastest | pip install lxml (6.1.3, BSD-3-Clause); the usual choice for scraping |
| html5lib | BeautifulSoup( | Slowest | Parses the way a browser does; most lenient with broken markup |
| lxml XML | BeautifulSoup( | Fast | For XML files and sitemaps |
Always name the parser. If you don't, Beautiful Soup picks whichever installed parser it ranks first and warns you, and the script may behave differently on another machine.
find() and find_all()
find() returns the first matching element or None; find_all() returns a list of every match. Both take a tag name, attributes, a class, text or a function.
soup.find("h3", class_="country-name") # first country name
soup.find_all("span", class_="country-capital") # every capital
soup.find_all("div", attrs={"class": "country"}, limit=10)
soup.find("a", href=True) # first link that has an href
import re
soup.find_all(string=re.compile("Andorra")) # text nodes that contain "Andorra"
soup.find_all("span", class_=re.compile(r"^country-")) # regex on the classThree traps:
string=tests a tag's.string, which isNonewhen the tag holds more than one child. On this pagesoup.find("h3", string=re.compile("Andorra"))returnsNone, because the<h3>holds an<i>icon as well as the text; search the text nodes (soup.find_all(string=...)) or use the CSS:-soup-contains("Andorra")instead.- The keyword is
class_(with an underscore; supported since Beautiful Soup 4.1.2). find()returnsNonewhen nothing matches, sosoup.find("h9").textraisesAttributeError: 'NoneType' object has no attribute 'text'. Check forNonebefore you read the value.
The full argument list is find_all(name, attrs, recursive, string, limit, **kwargs); the official docs explain each one under Searching the tree.
CSS selectors: select() and select_one()
If you already know CSS, select() is usually shorter. It returns a list; select_one() returns the first match or None.
soup.select("div.country h3.country-name") # descendant
soup.select("div.country > div.country-info") # direct child
soup.select('meta[property="og:title"]') # attribute equals
soup.select('a[href^="https://"]') # attribute starts with
soup.select("tr:nth-of-type(2) td") # position
soup.select_one("span.country-population")The selector engine is Soup Sieve, part of Beautiful Soup since 4.7.0; it supports CSS selectors through Level 4, including :is(), :not() and :has() (Soup Sieve docs). Since 4.12.0 the same engine is also reachable as soup.css.select(...); the official section is CSS selectors through the .css property.
find_all() and select() equivalents
Each pair below returned the same elements on the sandbox page on 2026-10-27:
| What you want | find / find_all | select / select_one |
|---|---|---|
| Tag with a class | soup. | soup. |
| First match | soup. | soup. |
| By id | soup. | soup. |
| Has an attribute | soup. | soup.select("a[href]") |
| Attribute value | soup. | soup. |
| Direct children only | card. | card. |
| First 10 matches | soup. | soup. |
| Contains text | soup. (text nodes) | soup. (tags) |
Extract text and attributes
from urllib.parse import urljoin
card = soup.select_one("div.country")
name = card.select_one("h3.country-name").get_text(strip=True) # "Andorra"
info = card.select_one("div.country-info").get_text(" ", strip=True)
link = soup.select_one("a[href]")
href = urljoin(url, link["href"]) # absolute URL
alt = soup.select_one("img").get("alt") # None instead of KeyError if missingget_text(strip=True)removes surrounding whitespace;get_text(" ", strip=True)joins nested pieces with a space.tag["href"]raisesKeyErrorwhen the attribute is missing;tag.get("href")returnsNone.- Relative links (
/page/2) needurljoinwith the page URL before you fetch them.
Scrape a table into rows
from urllib.parse import urljoin
page = "https://www.scrapethissite.com/pages/forms/"
r = requests.get(page, timeout=30)
r.raise_for_status()
soup = BeautifulSoup(r.text, "lxml")
headers = [th.get_text(strip=True) for th in soup.select("table th")]
rows = [[td.get_text(strip=True) for td in tr.select("td")] for tr in soup.select("tr.team")]
pages = [urljoin(page, a["href"]) for a in soup.select("ul.pagination a[href*='page_num']")]
print(len(rows), headers[:4], rows[0][:4])
print(pages[1])It prints 25 rows (one page of teams), the first four headers ['Team Name', 'Year', 'Wins', 'Losses'], and https://www.scrapethissite.com/pages/forms/?page_num=2: urljoin turns the relative href="/pages/forms/?page_num=2" into a URL you can fetch next.
For simple tables, pandas.read_html(r.text) returns them as DataFrames in one line; Beautiful Soup is better when cells hold links or nested tags you need.
XPath: use lxml next to Beautiful Soup
Beautiful Soup has no XPath support. When you want XPath (for example, to select by text or by position in ways CSS can't), parse the same HTML with lxml.html:
from lxml import html as lh
tree = lh.fromstring(r.text)
names = tree.xpath('//tr[@class="team"]/td[@class="name"]/text()')
wins_1991 = tree.xpath('//tr[td[@class="year" and normalize-space()="1991"]]/td[@class="wins"]/text()')
print([n.strip() for n in names][:3], [w.strip() for w in wins_1991][:3])Use whichever you read more easily; many scrapers mix both. More on choosing selectors in the HTML scraping guide.
Fetch through a proxy
Beautiful Soup parses whatever HTML you give it, so the proxy goes into the request, not into Beautiful Soup. With Requests:
proxy = "http://<login>-country-us-city-new_york:<password>@gate.proxyfield.io:7000"
s = requests.Session()
s.proxies.update({"http": proxy, "https": proxy})
soup = BeautifulSoup(s.get(url, timeout=30).text, "lxml")gate.proxyfield.io:7000 is the ProxyField gateway; <login>-country-us-city-new_york asks for an exit IP in New York. Region, session and session time go in the same username: -region-<region>, -session-<id> and -ttl-<seconds> (30 to 129,600 seconds, 20 minutes by default), as in <login>-country-us-region-new_york-city-new_york-session-he9f2qga-ttl-600; every part after the login is optional. The gateway can give each request a new IP, or keep one IP for a sticky session from 30 s to 36 h. Sessions, SOCKS5 and rotation in Python: Python proxy setup.
Common errors and what they mean
| Symptom | Cause | Fix |
|---|---|---|
AttributeError: 'NoneType' object has no attribute ... | The selector matched nothing | Print the HTML you received; check the selector in DevTools; guard with if el: |
| The element is in the browser but not in your soup | JavaScript added it after load | Find the JSON request, or render the page: Python dynamic web scraping |
Garbled characters (é) | Wrong encoding | Pass r.content (bytes) instead of r.text and let Beautiful Soup detect it |
| Different results on two machines | No parser named, different parsers installed | Always pass "lxml" or "html.parser" |
| An HTML page that says "Access denied" | The site blocked the request | Check the status code first; slow down, then use proxies |
Beautiful Soup vs Scrapy vs Selenium
| Feature | Beautiful Soup + Requests | Scrapy | Selenium or Playwright |
|---|---|---|---|
| What it is | Parser plus HTTP client | Crawling framework | Browser automation |
| JavaScript | No | Not by default | Yes |
| Best for | Single pages, small scripts, learning | Many pages, queues, retries, exports | Pages that need a real browser |
| Speed per page | Fast | Fast, concurrent | Slow (renders everything) |
Start with Beautiful Soup; move to Scrapy when you crawl thousands of pages, and to a browser only for the pages that need one. The step-by-step project is in how to build a web scraper.
Parsing pages from many sites? ProxyField residential proxies: 412M+ residential IPs in 195+ countries, a new IP per request, from $1.95/GB with no subscription; start with the Easy Trial (1 GB). Traffic never expires, and all purchases are final.
BeautifulSoup Web Scraping Tutorial FAQ
How do I scrape a website with Beautiful Soup in Python, using find_all() and CSS selectors to pull out the fields?
ProxyField's script above pulls 250 rows of four fields in about 30 lines: fetch with requests.get() and raise_for_status(), parse with BeautifulSoup(html, "lxml"), loop over soup.find_all("div", class_="country"), and read each field with card.find(...) and get_text(strip=True). The CSS version loops over soup.select("div.country") and uses card.select_one("span.country-capital"); both give identical rows. Guard each field against None and write the dicts with csv.DictWriter.
Should I use find_all() or select() in Beautiful Soup?
Either: on the same page they return the same elements, as the equivalents table above shows. select() is shorter when you already have a CSS selector from DevTools; find_all() is better when you filter with a Python function or a regex on an attribute.
Why does find() with string= return None when I can see the text?
string= compares against the tag's .string, which is None when the tag has more than one child, such as an icon <i> next to the text. Search the text nodes with soup.find_all(string=re.compile("...")) and take .parent, or use the CSS :-soup-contains("...") selector.
Is Beautiful Soup good for web scraping?
Yes, for pages whose data is in the HTML the server sends: it is simple, forgiving with broken markup and fast enough with the lxml parser. It is not enough on its own for pages built by JavaScript or for large crawls, where you add a browser tool or a framework.
What is Beautiful Soup used for?
Parsing HTML and XML: finding elements by tag, class, attribute or text, reading their text and attributes, and walking the tree. Scrapers use it to turn downloaded pages into rows of data, and it also cleans up or rewrites HTML.
Can Beautiful Soup handle JavaScript-loaded content?
No. Beautiful Soup only parses the HTML you give it and never runs scripts. Get the data from the JSON request the page makes, or render the page with Playwright or Selenium and pass page.content() to Beautiful Soup.
What is the difference between find() and find_all() in Beautiful Soup?
find() returns the first matching element, or None if nothing matches; find_all() returns a list of every match, empty if none. select_one() and select() are the CSS-selector versions of the same pair.
How do I use XPath for web scraping in Python?
Parse the HTML with lxml.html.fromstring() and call .xpath() on the tree, for example tree.xpath('//td[@class="name"]/text()'). Beautiful Soup itself has no XPath support, so many scrapers use lxml for XPath and Beautiful Soup for CSS selectors on the same page.
Is lxml faster than Beautiful Soup?
Yes, used directly lxml is faster, because Beautiful Soup adds its own layer on top of the parser. Passing "lxml" as Beautiful Soup's parser closes much of the gap, and for most scrapers the network, not the parser, is the slow part.
Can Beautiful Soup handle broken HTML?
Yes. It repairs unclosed tags and bad nesting through its parser; html5lib repairs the way a browser does, lxml and html.parser a little differently. If a page parses oddly, try another parser and compare.
Show 5 more questionsShow fewer questions
Is Beautiful Soup a web crawler?
No. It parses one document at a time and never fetches pages or follows links. A crawler around it needs a fetcher, a queue of URLs and rules for which links to follow, or a framework such as Scrapy.
Is Beautiful Soup better than Selenium?
They do different jobs: Beautiful Soup parses HTML quickly, and Selenium drives a real browser that runs JavaScript. Use Beautiful Soup when the data is in the page source, and Selenium only for pages that need a browser, often passing its page_source to Beautiful Soup.
Is Beautiful Soup easy to learn?
Yes; it is one of the easiest Python libraries to start with. Most scrapers need only BeautifulSoup(), select(), find_all(), get_text() and get(), and the official documentation has examples for each.
Why is it called Beautiful Soup?
"Tag soup" is the old name for messy, badly formed HTML, which the library was written to parse. The name also borrows the "Beautiful Soup" song of the Mock Turtle in Lewis Carroll's Alice's Adventures in Wonderland.
What are the disadvantages of Beautiful Soup?
It runs no JavaScript, has no XPath, does not fetch pages, and is slower than lxml used directly. It also has no built-in concurrency, retries or crawling, which a framework such as Scrapy provides.
Related pages
Easy Trial: 1 GB for $2.95.
Try for the price of 1 GB, no subscription, traffic never expires.