Quick Reference · pull data out of HTML & XML · Python

BeautifulSoup cheat sheet · bs4 · expanded

Feed it messy markup and a parser turns it into a tree of Python objectsTag nodes and NavigableString text. From there you do three things: navigate the family (parents, children, siblings), search it (find_all & CSS select), and extract what you want (.get_text(), attributes). It doesn’t fetch pages — pair it with requests for that.

setup / parsers objects & tags search: find · select navigate the tree modify the tree extract & output gotcha most common

Introspected from bs4 4.14, updated for 4.15.0 (7 Jun 2026), & cross-checked across: crummy.com/…/bs4/doc (official) · Real Python · ScrapeOps · DataCamp · tutorialspoint · Soup Sieve (CSS) docs · re-verified 2026-08-30: beautifulsoup4 4.15.0 current; Python 3.7+

Markup → a parser builds a tree → navigate, search, extract
BAND A · THE PIPELINE Raw markup a str or bytes of HTML / XML <div><p>Hi <b>there</b></p></div> often requests(url).content Parser html.parser (stdlib) lxml (fast) html5lib (lenient) BeautifulSoup(html, …) Parse tree html head body title p a "Hi" Tag nodes (amber) · text = NavigableString (green) … then you do 4 things SEARCH find/select NAVIGATE family EXTRACT text/attrs MODIFY edit/build soup.find_all("a") · soup.select("a.cls") tag.parent · tag.get_text(strip=True) tag["href"] · tag.decompose() BAND B · THE FOUR OBJECT TYPES YOU’LL MEET IN THE TREE BeautifulSoup the whole parsed document (the root; acts like a Tag) soup = BeautifulSoup(html,     "html.parser") it contains Tags, which contain more Tags and strings… Tag an element: <a href="/x">…</a> .name → "a" .attrs → {"href": "/x"} tag["href"] → "/x" .contents / .children has parents, children, siblings — the searchable, navigable node NavigableString the text inside a tag it IS a Python str subclass "Hello world" no .contents, no find() — a leaf, not a branch get it via .string / .strings / .get_text() Comment & friends: CData, Doctype, Declaration <!-- like this --> special NavigableString subclasses — text that isn’t page content isinstance(s, Comment)
quickstart.py — the canonical requests + BeautifulSoup combo
import requests
from bs4 import BeautifulSoup

html = requests.get("https://example.com").content   # bytes → let BS4 detect encoding
soup = BeautifulSoup(html, "html.parser")             # always name a parser

# SEARCH — CSS selector returns a list
for a in soup.select("a[href]"):
    print(a.get_text(strip=True), "→", a["href"])   # extract text + attribute

# find() returns ONE tag or None (note class_, not class)
h = soup.find("h1", class_="headline")
print(h.get_text(strip=True) if h else "no headline")
01Setup & Importinstall once
02Make the Soup & Parserspick one
03The Four Object Typeswhat's in the tree
04Inspect a Tagname · attrs
05find & find_allthe workhorse
06Search by Attributes & Textfilter the match
07Function & Custom Filterswhen kwargs aren't enough
08CSS Selectors · Basicssoupsieve
09CSS Selectors · Attributes & Pseudothe scraping power tools
10Directional Searchfind near a tag
11Navigate Downinto children
12Navigate Up & Sidewaysparents · siblings
13Extract Textget the words out
14Extract Attributeshref · src · class
15Modify · Edit & Buildchange the tree
16Modify · Delete & Replaceremove nodes
17Output & Formattingtree → string
18Encoding & Unicodebytes in, str out
19Tables & Structured Datathe #1 scrape
20Real-World Scrapingthe full loop
21Common Recipescopy-paste
22Performance & SoupStrainergo faster

Six pictures that make BeautifulSoup click

How markup becomes a tree, how you move around it, the two ways to search, what the text accessors return, how the parsers differ, and the whole find_* family at a glance.

1 · From markup to tree

Each tag becomes a Tag node; the text between tags becomes a NavigableString leaf.

<div class="c"> <p>Hi <b>there</b> </p> </div> div p "Hi" b "there" Tag NavigableString div.get_text() → "Hithere" div.b.string → "there"

2 · Moving around the family

Relations are relative to a node. Beware: next_element descends into children; next_sibling stays at the same level (and is often whitespace).

div p (you) p "Hi" b .parent .children .next_sibling .next_element (into b!) ⚠ .next_sibling is often the "\n" between tags → use find_next_sibling()

3 · find_all vs select — same goal

Two syntaxes to reach the same tags: keyword filters, or a CSS selector string.

find_all( … ) name find_all("a", class_="btn", attrs={"data-x":"1"}) Pythonic keywords · regex · funcs select( … ) one CSS string select( "a.btn[data-x='1']" ) combinators · :nth · > ~ + a list of Tags first-only twins: find() · select_one() find() → Tag or None  |  find_all() → [] if none

4 · .string vs .get_text() vs .stripped_strings

For <p>Hello <b>world</b></p> the three text accessors give three different results.

<p>Hello <b>world</b></p> .string None 2+ children → ambiguous .get_text() "Hello world" all descendant text joined .stripped_strings ["Hello",  "world"] each chunk, trimmed Rule of thumb reach for get_text(strip=True) — .string only works on a single-child tag

5 · Choosing a parser

Same API, different trade-offs. The parser also decides how broken HTML gets repaired — so results can differ.

speed leniency needs "html.parser" stdlib default safe everywhere ●●○ ●●○ nothing "lxml" the usual pick for real work ●●● ●●○ pip lxml "html5lib" parses exactly like a browser ●○○ ●●● pip html5lib ⚠ broken HTML → each can build a DIFFERENT tree. Pin one parser per project. start with lxml; fall back to html.parser if you can't install it

6 · The find_* family, by direction

Every navigation direction has a "first match" and an "all matches" method. Learn the grid, not the 10 names.

first match all matches (list) DOWN into descendants find() find_all() UP to ancestors find_parent() find_parents() SIDEWAYS same level find_next_sibling() find_previous_sibling() find_next_siblings() find_previous_siblings() FORWARD later, any depth find_next() find_all_next() BACKWARD earlier, any depth find_previous() find_all_previous() same filters as find_all everywhere · select()/select_one() cover DOWN via CSS

Worth memorizing

class_ not classfind_all("div", class_="x")class is a reserved word
.string can be Nonea tag with 2+ children → None; use get_text()
class → a listtag["class"] returns ["a","b"], not a string
name the parserBeautifulSoup(html, "html.parser") — else a warning + drift
parsers differlxml fast · html.parser stdlib · html5lib browser-lenient
whitespace siblings.next_sibling is often "\n" — use find_next_sibling()
find vs find_allfind → Tag or None · find_all → list (maybe empty)
soup("a") == find_alland soup.a == soup.find("a") (first only)
pass .contentgive BS4 bytes (not .text) so it can sniff encoding
select → listselect_one() is the first-only twin, like find
attr operators[href^=], [href$=], [href*=] = starts / ends / contains
:-soup-contains()select by text; the modern name for CSS :contains
string=search by text (takes str, regex, list, or True)
tag["x"] vs .getbracket raises KeyError; .get("x") returns None
decompose vs extractdestroy in place vs remove-and-return; clear() empties
attrs={} for nameshyphenated / reserved attrs: attrs={"data-id": 7}
can't run JSdynamic pages → Selenium / Playwright, then feed HTML to BS4
it doesn't fetchno HTTP — pair with requests / httpx
urljoin for linksurljoin(base, href) turns relative URLs absolute
next_element ≠ next_siblingelement = parse order (descends); sibling = same level