From BeautifulSoup¶
BeautifulSoup is the long-standing convenience layer over a
choice of HTML parsers (html.parser, lxml, or html5lib): you pick a backend, then navigate and search the
resulting soup with a large, alias-rich API. It also parses XML through the lxml-xml backend, decodes unknown byte
streams with UnicodeDammit, and matches CSS selectors through its soupsieve dependency. Its reach and forgiving
API made it the default scraping tool for a generation of Python code, from one-off screen scrapes to production
pipelines.
turbohtml covers the HTML side of that ground with one engine. turbohtml.parse() runs the WHATWG algorithm in C,
returns a fully type annotated Document, and exposes a single find/find_all/select
grammar plus XPath. It shares the most surface with turbohtml of any library here, so this is the deepest migration
section.
turbohtml vs BeautifulSoup¶
Dimension |
turbohtml |
BeautifulSoup |
|---|---|---|
Scope |
WHATWG HTML: parse, query, mutate, and serialize in one C engine |
HTML and XML navigation layer over a pluggable third-party parser |
Feature breadth |
|
|
Performance |
C core; one to three orders of magnitude faster than |
Pure-Python navigation; speed tracks the chosen backend and is slowest on |
Typing |
Ships |
No inline types; relies on the third-party |
Dependencies |
Zero runtime dependencies (self-contained C extension) |
Requires |
Maintenance |
Actively developed, single-engine |
Mature, widely deployed, long release history |
Feature overlap¶
These port one-to-one; the calls differ only in name (see the mapping table under How to migrate):
Parsing markup into a navigable tree.
find/find_allby tag, attribute,class_, and text.CSS selectors via
select/select_one.Directional navigation (parents, siblings, next/previous elements) — turbohtml folds these into
findwith anAxis.Tree mutation:
decompose,extract,unwrap,wrap,insert_before,insert_after,replace_with.Text access:
get_textmaps totext,strings, andstripped_strings.Pretty-printing via
serialize()withHtml(layout=Indent(2))forprettify().Source positions:
sourceline/sourceposmap tosource_line/source_col.
What turbohtml adds¶
XPath.
xpath()andxpath_iter()evaluate expressions with namespaces, variables, and extension functions. BeautifulSoup has no XPath support at all.A C engine. Parsing, querying, text collection, and serialization all run in C, so migrating off
html.parseris a large speedup with no backend to install.WHATWG-conformant serialization.
Formatterselection controls entities, and output matches the spec by default;Formatter.NAMED_ENTITIESreproducesbs4’shtmlformatter when you need it.Post-parse pruning.
prune()trims a fully parsed tree to a CSS selector in one C pass.Zero runtime dependencies and full typing. No
soupsieveinstall, and every public API is annotated.
What BeautifulSoup has that turbohtml does not¶
XML parsing.
BeautifulSoup(markup, "lxml-xml")parses arbitrary XML. turbohtml runs the WHATWG HTML algorithm only; there is no XML tree-builder. No equivalent — keeplxmlfor XML documents.Pluggable parser backends.
bs4lets you swaphtml.parser,lxml, andhtml5lib. turbohtml always runs its own WHATWG parser; this is a deliberate clean-break omission, not a gap you work around.Statistical encoding detection.
UnicodeDammit(andchardet/charset-normalizer) guess an encoding from byte frequency when there is no BOM or<meta charset>. turbohtml sniffs only what the WHATWG algorithm reads, then falls back towindows-1252. Workaround: detect withcharset-normalizerfirst and hand turbohtml the decodedstr(or bytes with an explicitencoding=).A named output-formatter registry.
bs4registers custom formatters by name. Workaround: pass aFormatterperserialize()call.
Performance¶
turbohtml parses, queries, and serializes one to three orders of magnitude faster than BeautifulSoup over
html.parser — including text filtering (find(text=...) against find_all(string=...)), walking the tree
(descendants against soup.descendants), and reading text (text
against soup.get_text()):
operation |
turbohtml |
|
|---|---|---|
build a list (constructors) — 100 rows |
55.7 µs |
1.03 ms (18.6x) |
build a list (constructors) — 1k rows |
566 µs |
8.63 ms (15.3x) |
build a list (constructors) — 10k rows |
6.41 ms |
95.4 ms (14.9x) |
construct N elements (no serialize) — 100 rows |
43.4 µs |
265 µs (6.2x) |
construct N elements (no serialize) — 1k rows |
463 µs |
2.59 ms (5.6x) |
construct N elements (no serialize) — 10k rows |
4.66 ms |
25.4 ms (5.5x) |
emit a built tree — 100 rows |
4.19 µs |
394 µs (94.0x) |
emit a built tree — 1k rows |
42.4 µs |
4.01 ms (94.8x) |
emit a built tree — 10k rows |
420 µs |
41.7 ms (99.4x) |
parse to a tree — wpt tiny (0.6 kB) |
1.21 µs |
60.8 µs (50.5x) |
parse to a tree — wpt small (4 kB) |
9.57 µs |
435 µs (45.5x) |
parse to a tree — wpt medium (9.6 kB) |
24.2 µs |
853 µs (35.3x) |
parse to a tree — wpt large (92 kB) |
207 µs |
15.1 ms (73.0x) |
parse to a tree — wpt CJK (124 kB) |
407 µs |
21.9 ms (53.9x) |
parse to a tree — whatwg spec (235 kB) |
405 µs |
25.5 ms (62.9x) |
find every anchor — daring fireball (10 kB) |
375 ns |
13.5 µs (36.2x) |
find every anchor — ars technica (56 kB) |
828 ns |
50.5 µs (61.1x) |
find every anchor — mozilla blog (95 kB) |
1.18 µs |
107 µs (90.9x) |
find every anchor — whatwg spec (235 kB) |
1.32 µs |
393 µs (299x) |
select div a[href] — daring fireball (10 kB) |
639 ns |
168 µs (264x) |
select div a[href] — ars technica (56 kB) |
1.46 µs |
566 µs (388x) |
select div a[href] — mozilla blog (95 kB) |
2.07 µs |
1.07 ms (519x) |
select div a[href] — whatwg spec (235 kB) |
1.74 µs |
2.74 ms (1575x) |
select div:has(a) — daring fireball (10 kB) |
256 ns |
112 µs (438x) |
select div:has(a) — ars technica (56 kB) |
1.28 µs |
486 µs (381x) |
select div:has(a) — mozilla blog (95 kB) |
8.97 µs |
2.18 ms (243x) |
select div:has(a) — whatwg spec (235 kB) |
6.08 µs |
3.16 ms (519x) |
find by text content — daring fireball (10 kB) |
42.9 µs |
55.9 µs (1.4x) |
find by text content — ars technica (56 kB) |
431 µs |
221 µs (0.6x) |
find by text content — mozilla blog (95 kB) |
329 µs |
1.24 ms (3.8x) |
find by text content — whatwg spec (235 kB) |
638 µs |
3.06 ms (4.8x) |
collect visible text — daring fireball (10 kB) |
2.69 µs |
19 µs (7.1x) |
collect visible text — ars technica (56 kB) |
13.4 µs |
76.5 µs (5.8x) |
collect visible text — mozilla blog (95 kB) |
23.3 µs |
166 µs (7.2x) |
collect visible text — whatwg spec (235 kB) |
76.8 µs |
588 µs (7.7x) |
serialize a parsed tree — daring fireball (10 kB) |
7.14 µs |
430 µs (60.4x) |
serialize a parsed tree — ars technica (56 kB) |
40.7 µs |
1.86 ms (45.8x) |
serialize a parsed tree — mozilla blog (95 kB) |
79.2 µs |
4.17 ms (52.7x) |
serialize a parsed tree — whatwg spec (235 kB) |
209 µs |
11.1 ms (53.2x) |
tag every link rel=nofollow — daring fireball (10 kB) |
3.32 µs |
27.3 µs (8.3x) |
tag every link rel=nofollow — ars technica (56 kB) |
13.2 µs |
61.7 µs (4.7x) |
tag every link rel=nofollow — mozilla blog (95 kB) |
21 µs |
128 µs (6.2x) |
tag every link rel=nofollow — whatwg spec (235 kB) |
42.5 µs |
411 µs (9.7x) |
class add/remove on every link — daring fireball (10 kB) |
2.2 µs |
21 µs (9.6x) |
class add/remove on every link — ars technica (56 kB) |
8.55 µs |
69.8 µs (8.2x) |
class add/remove on every link — mozilla blog (95 kB) |
8.48 µs |
129 µs (15.3x) |
class add/remove on every link — whatwg spec (235 kB) |
8.08 µs |
373 µs (46.2x) |
drop tags with content (remove) — daring fireball (10 kB) |
25.3 µs |
1.48 ms (58.4x) |
drop tags with content (remove) — ars technica (56 kB) |
125 µs |
6.52 ms (52.0x) |
drop tags with content (remove) — mozilla blog (95 kB) |
283 µs |
14.7 ms (51.9x) |
drop tags with content (remove) — whatwg spec (235 kB) |
687 µs |
39.7 ms (57.8x) |
unwrap tags keep content (strip_tags) — daring fireball (10 kB) |
25.8 µs |
1.5 ms (58.3x) |
unwrap tags keep content (strip_tags) — ars technica (56 kB) |
127 µs |
6.74 ms (53.0x) |
unwrap tags keep content (strip_tags) — mozilla blog (95 kB) |
286 µs |
14.4 ms (50.5x) |
unwrap tags keep content (strip_tags) — whatwg spec (235 kB) |
742 µs |
39.6 ms (53.4x) |
replace body inner HTML — daring fireball (10 kB) |
2.33 µs |
90.1 µs (38.7x) |
replace body inner HTML — ars technica (56 kB) |
9.43 µs |
145 µs (15.4x) |
replace body inner HTML — mozilla blog (95 kB) |
15.6 µs |
227 µs (14.6x) |
replace body inner HTML — whatwg spec (235 kB) |
45.4 µs |
861 µs (19.0x) |
replace body text — daring fireball (10 kB) |
1.19 µs |
23.9 µs (20.2x) |
replace body text — ars technica (56 kB) |
7.72 µs |
72.4 µs (9.4x) |
replace body text — mozilla blog (95 kB) |
13 µs |
137 µs (10.6x) |
replace body text — whatwg spec (235 kB) |
39.8 µs |
802 µs (20.2x) |
walk every descendant — daring fireball (10 kB) |
3.34 µs |
6.69 µs (2.1x) |
walk every descendant — ars technica (56 kB) |
13.8 µs |
26 µs (1.9x) |
walk every descendant — mozilla blog (95 kB) |
28.2 µs |
54.8 µs (2.0x) |
walk every descendant — whatwg spec (235 kB) |
101 µs |
193 µs (2.0x) |
extract @href per match — daring fireball (10 kB) |
2.58 µs |
99.5 µs (38.6x) |
extract @href per match — ars technica (56 kB) |
6.65 µs |
362 µs (54.5x) |
extract @href per match — mozilla blog (95 kB) |
9.23 µs |
770 µs (83.5x) |
extract @href per match — whatwg spec (235 kB) |
9.81 µs |
2.46 ms (251x) |
extract text per match — daring fireball (10 kB) |
2.69 µs |
108 µs (40.1x) |
extract text per match — ars technica (56 kB) |
6.68 µs |
368 µs (55.2x) |
extract text per match — mozilla blog (95 kB) |
10.2 µs |
1.75 ms (172x) |
extract text per match — whatwg spec (235 kB) |
10.9 µs |
4.32 ms (397x) |
detect a byte stream’s encoding — ascii (1 kB) |
1.89 µs |
1.47 µs (0.8x) |
detect a byte stream’s encoding — utf-8 russian (4 kB) |
3.64 µs |
3.23 µs (0.9x) |
detect a byte stream’s encoding — windows-1251 russian (4 kB) |
185 µs |
3.59 µs (0.1x) |
detect a byte stream’s encoding — windows-1252 french (4 kB) |
186 µs |
3.46 µs (0.1x) |
detect a byte stream’s encoding — shift_jis japanese (4 kB) |
708 µs |
23.6 µs (0.1x) |
detect a byte stream’s encoding — utf-8 page (95 kB) |
642 ns |
15.4 µs (24.0x) |
The Performance page benchmarks the build and edit paths against BeautifulSoup too.
How to migrate¶
Swap the import and the constructor. There is no parser name to pass, since turbohtml always runs the WHATWG algorithm:
# BeautifulSoup
from bs4 import BeautifulSoup
soup = BeautifulSoup(markup, "html.parser")
from turbohtml import parse
doc = parse("<p id=intro>Hello</p>")
print(doc.find("p").attrs["id"])
intro
Bytes work too; pass the raw response and read the resolved encoding back from Document.encoding:
doc = parse(b'<meta charset="latin-1"><p>caf\xe9</p>')
print(doc.find("p").text)
print(doc.encoding) # the WHATWG label latin-1 resolves to
café
windows-1252
API mapping¶
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(no XPath) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The find/find_all filter grammar covers what bs4 spread across many methods. A keyword filter matches an
attribute; class_ and attrs match the rest; axis replaces the directional finders and recursive=False:
from turbohtml import Axis
doc = parse('<ul><li class="x">a</li><li class="y">b</li></ul>')
print([li.text for li in doc.find_all("li")])
print(doc.find("li", class_="y").text)
print(doc.find("ul").find_all("li", axis=Axis.CHILDREN, attrs={"class": "x"}))
['a', 'b']
b
[Element('li')]
Axis reaches every direction a bs4 directional finder did:
deep = parse("<section><p><b>hi</b></p></section>").find("b")
print(deep.find("section", axis=Axis.ANCESTORS).tag)
print(deep.closest("section").tag)
section
section
Gotchas and pitfalls¶
Indexing reaches children, not attributes.
node[i]indexes child nodes, so attributes are reached only through.attrs, nevernode["attr"]. Multi-valued attributes (class,rel, …) read back as alist[str].a = parse('<a class="btn lg" href="/x">go</a>').find("a") print(a.attrs["class"]) print(a[0]) # indexing reaches children, never attributes
['btn', 'lg'] Text('go')No ``.string`` shortcut and no ``text``/``tail`` split. Text is real child nodes (the WHATWG DOM shape), so there is no
.stringand no lxml-styletext/tail; iterate the children or readtext:p = parse("<p>Hello <b>bold</b> world</p>").find("p") print((p.text, list(p.stripped_strings)))
('Hello bold world', ['Hello', 'bold', 'world'])``text=`` filters elements, not ``NavigableString``.
bs4’sfind(string=...)returns the matching string node; turbohtml’stext=filters elements by their collected subtree text, so it composes with tag and attribute filters. A plain string matches the full text; use a regex to search within:import re doc = parse("<ul><li>Buy now</li><li>Later</li></ul>") print(doc.find("li", text="Buy now").text) print([li.text for li in doc.find_all("li", text=re.compile(r"now"))])
Buy now ['Buy now']
Serialization is WHATWG-conformant by default. Output differs from
bs4’shtmlformatter on named entities, attribute order, and<br>versus<br/>. PickFormatter.NAMED_ENTITIESto approximatebs4:from turbohtml import Formatter, Html node = parse("<p>café & co</p>").find("p") print(node.html) print(node.serialize(Html(formatter=Formatter.NAMED_ENTITIES)))
<p>café & co</p> <p>café & co</p>
Encoding sniffing stops at the markup.
parseruns the WHATWG algorithm on bytes — BOM, then a<meta charset>prescan, then awindows-1252fallback — which covers whatUnicodeDammitreads from the markup but does not guess from byte frequency. A stream with no BOM and no declaration lands onwindows-1252wherecharset-normalizermight name, say,koi8-r. When there is nothing to sniff, detect first and hand turbohtml the decodedstr.``==`` compares identity; structural equality is a method. Two parses of the same markup are
==-unequal (==stays node identity, so nodes remain usable as dict keys). Wherebs4leaned on structural==, callequals():a.equals(b)compares two subtrees by name, attributes, and contents, thebs4notion of tree equality.``SoupStrainer`` has no parse-time equivalent. turbohtml always runs the full WHATWG algorithm, then
prune()trims the parsed tree to a CSS selector in one C pass, so a large document still yields a small tree — but the whole document is parsed first.