Tutorial9 min read

BeautifulSoup Web Scraping Tutorial

Short answer

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
  1. What Beautiful Soup does, and what it doesn't
  2. Install
  3. The full scraper: find_all() and select() on the same cards
  4. Choose a parser
  5. find() and find_all()
  6. CSS selectors: select() and select_one()
  7. Extract text and attributes
  8. Navigate the tree
  9. Scrape a table into rows
  10. XPath: use lxml next to Beautiful Soup
  11. Fetch through a proxy
  12. Common errors and what they mean
  13. Beautiful Soup vs Scrapy vs Selenium
  14. 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 doesBeautiful Soup does not
Parse HTML, including broken markup, into a treeDownload pages (use Requests or httpx)
Find elements by tag, class, id, attribute or textRun 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 siblingsCrawl, queue or retry (use Scrapy, or your own loop)
Fix encodings and output clean HTMLRotate IPs (send the request through a proxy)

Install

Shell
pip install beautifulsoup4 lxml requests

Versions 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:

XML
<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:

Python
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.0

Why each line is there:

  • raise_for_status() turns a blocked or missing page into an HTTPError. 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 contains country, so col-md-4 country matches; the underscore is there because class is a Python keyword.
  • The text() guard returns None for a missing field instead of raising AttributeError: '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.

ParserHow to call itSpeedNotes
html.parserBeautifulSoup(html, "html.parser")ModerateBuilt into Python, nothing to install
lxmlBeautifulSoup(html, "lxml")Fastestpip install lxml (6.1.3, BSD-3-Clause); the usual choice for scraping
html5libBeautifulSoup(html, "html5lib")SlowestParses the way a browser does; most lenient with broken markup
lxml XMLBeautifulSoup(xml, "xml")FastFor 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.

Python
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 class

Three traps:

  • string= tests a tag's .string, which is None when the tag holds more than one child. On this page soup.find("h3", string=re.compile("Andorra")) returns None, 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() returns None when nothing matches, so soup.find("h9").text raises AttributeError: 'NoneType' object has no attribute 'text'. Check for None before 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.

Python
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 wantfind / find_allselect / select_one
Tag with a classsoup.find_all("div", class_="country")soup.select("div.country")
First matchsoup.find("h3", class_="country-name")soup.select_one("h3.country-name")
By idsoup.find(id="countries")soup.select_one("#countries")
Has an attributesoup.find_all("a", href=True)soup.select("a[href]")
Attribute valuesoup.find("meta", attrs={"name": "description"})soup.select_one('meta[name="description"]')
Direct children onlycard.find_all("div", recursive=False)card.select(":scope > div")
First 10 matchessoup.find_all("span", class_="country-capital", limit=10)soup.select("span.country-capital", limit=10)
Contains textsoup.find_all(string=re.compile("Andorra")) (text nodes)soup.select('h3:-soup-contains("Andorra")') (tags)

Extract text and attributes

Python
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 missing
  • get_text(strip=True) removes surrounding whitespace; get_text(" ", strip=True) joins nested pieces with a space.
  • tag["href"] raises KeyError when the attribute is missing; tag.get("href") returns None.
  • Relative links (/page/2) need urljoin with the page URL before you fetch them.

Scrape a table into rows

Python
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:

Python
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:

Python
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

SymptomCauseFix
AttributeError: 'NoneType' object has no attribute ...The selector matched nothingPrint the HTML you received; check the selector in DevTools; guard with if el:
The element is in the browser but not in your soupJavaScript added it after loadFind the JSON request, or render the page: Python dynamic web scraping
Garbled characters (é)Wrong encodingPass r.content (bytes) instead of r.text and let Beautiful Soup detect it
Different results on two machinesNo parser named, different parsers installedAlways pass "lxml" or "html.parser"
An HTML page that says "Access denied"The site blocked the requestCheck the status code first; slow down, then use proxies

Beautiful Soup vs Scrapy vs Selenium

FeatureBeautiful Soup + RequestsScrapySelenium or Playwright
What it isParser plus HTTP clientCrawling frameworkBrowser automation
JavaScriptNoNot by defaultYes
Best forSingle pages, small scripts, learningMany pages, queues, retries, exportsPages that need a real browser
Speed per pageFastFast, concurrentSlow (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.

Easy Trial: 1 GB for $2.95.

Try for the price of 1 GB, no subscription, traffic never expires.