From bleach¶
bleach was the standard Python HTML allowlist sanitizer and linkifier, built on
html5lib. Two jobs live in one library: bleach.clean strips markup down to an allowed set of tags, attributes, and
URL schemes so user-supplied HTML is safe to render, and bleach.linkify scans plain text for URLs and wraps them in
<a> tags. It powered comment fields, wikis, and message bodies across the Django ecosystem for a decade.
bleach reached end of life with no maintained successor. turbohtml covers both jobs from its turbohtml.clean module:
bleach.clean maps to the allowlist Sanitizer (with a drop-in
turbohtml.migration.bleach.clean() shim), and bleach.linkify maps to turbohtml.clean.linkify(). Both run
their filtering in C, ship full type annotations, and take a frozen, thread-safe configuration.
turbohtml vs bleach¶
Dimension |
turbohtml |
bleach |
|---|---|---|
Scope |
Full WHATWG parser, serializer, sanitizer, linkifier, selectors |
Sanitize and linkify only, over html5lib |
Feature breadth |
Escape/strip/remove per tag, value-rewriting attribute filter, forced attributes, regenerable IANA TLD table |
Allow/strip tags, bool attribute callback, |
Performance |
Filtering and link scan in C |
Pure-Python over html5lib |
Typing |
Fully annotated, |
Untyped |
Dependencies |
None (self-contained C extension) |
html5lib, plus tinycss2 for CSS sanitizing |
Maintenance |
Active |
End of life, no successor |
Feature overlap¶
The shared surface ports one-to-one:
bleach.clean(text, tags=, attributes=, protocols=, strip=, strip_comments=)->turbohtml.migration.bleach.clean()(same signature) or a nativePolicy+Sanitizer.bleach.linkify(text, ...)and the reusableLinker->turbohtml.clean.linkify()andturbohtml.clean.Linker.The
nofollowandtarget_blankcallbacks andDEFAULT_CALLBACKSkeep their names inturbohtml.clean.bleach’s default allowed tags, attributes, and URL schemes are the turbohtml migration baseline (
DEFAULT_TAGS,DEFAULT_ATTRIBUTES,DEFAULT_SCHEMES), so an unconfiguredcleanbehaves the same.
What turbohtml adds¶
A frozen, thread-safe
Policy, where sharing a configuredbleach.Cleaneracross threads was a documented footgun.An
OnDisallowedenum that names escape, strip, and remove, where bleach overloaded the two booleansstripandstrip_comments.An
attribute_filterthat returns a replacement value orNoneto drop, where bleach’s attribute callable only returned a bool.set_attributesto force attributes (for examplerel="noopener") onto every kept instance of a tag, which bleach could only approximate through a linkify callback.A safety baseline that removes
<script>,on*handlers, andjavascript:URLs by construction, so no policy can re-admit them.An IANA TLD table you can regenerate and extend with
Linkify.extra_tlds, where bleach shipped a frozen list.Idempotent linkifying: an existing
<a>is left untouched unless you opt in withLinkify.process_existing.
What bleach has that turbohtml does not¶
A pluggable html5lib filter pipeline (
bleach.sanitizer.Cleaner(filters=[...])) let you insert arbitrary html5libFilterclasses into the clean pass. turbohtml has no equivalent filter chain; express the transform throughattribute_filter,set_attributes, or a post-parse walk over the tree instead.A fully configurable safety baseline. bleach kept whatever you listed, including
<script>if you allowed it; turbohtml’s baseline is fixed. No equivalent, by design.
Performance¶
The sanitizer leads bleach by an order of magnitude and the linkifier by five to twenty times:
input |
turbohtml |
|
|---|---|---|
sanitize — comment |
1.57 µs |
87.8 µs (56.1x) |
sanitize — post 4 KiB |
42.5 µs |
2.19 ms (51.4x) |
linkify HTML — comment (1 link, 1 email) |
3.14 µs |
57.2 µs (18.2x) |
linkify HTML — prose (1 KiB) |
52.3 µs |
293 µs (5.7x) |
linkify HTML — markup (4 KiB) |
130 µs |
1.68 ms (13.0x) |
How to migrate¶
Sanitizing¶
The bleach-compatible shim keeps clean’s signature, so the import is the only change:
# bleach
from bleach import clean
# turbohtml
from turbohtml.migration.bleach import clean
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
clean(text, tags=..., attributes=..., protocols=..., strip=..., strip_comments=...) maps onto a
Policy. attributes accepts bleach’s list, per-tag dict, or callable forms; strip
chooses between dropping a disallowed tag and keeping its children (True) and escaping it (False, the default):
from turbohtml.migration.bleach import clean
print(clean("<p>Hi <a href='http://x'>link</a></p><script>evil()</script>"))
<p>Hi <a href="http://x">link</a></p><script>evil()</script>
For new code prefer the native Policy/Sanitizer API: a frozen,
thread-safe policy, an OnDisallowed enum that names escape, strip, and remove where bleach
overloaded two booleans, and an attribute_filter that rewrites or drops a value where bleach’s callable only
returned a bool.
Linkifying¶
The entry points keep bleach’s names, so the import changes and the common case is identical:
# bleach
from bleach import linkify
from bleach.linkifier import Linker, DEFAULT_CALLBACKS
from bleach.callbacks import nofollow, target_blank
# turbohtml
from turbohtml.clean import linkify, Linker, Linkify, DEFAULT_CALLBACKS, nofollow, target_blank
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
callback |
a single |
|
|
|
|
linkify(text, Linkify(callbacks=..., skip_tags=..., parse_email=...)), the reusable
Linker, and the nofollow/target_blank defaults work as before: the six knobs are now
fields of a frozen Linkify config. Only custom callbacks change shape. bleach passed (attrs,
new) where attrs was keyed by (namespace, name) tuples with a "_text" pseudo-key for the text; turbohtml
passes a single LinkCandidate with plain url, text, and attrs (a dict[str,
str]), and a callback returns it to keep the link or None to leave the text bare. bleach’s new flag becomes
LinkCandidate.existing (inverted: new=True is existing=False). Porting a callback means reading fields
instead of tuple keys:
from turbohtml.clean import LinkCandidate, Linkify, linkify
def shorten(link: LinkCandidate) -> LinkCandidate | None:
link.text = link.url.removeprefix("https://").removeprefix("http://")
return link
print(linkify("read https://example.com/page", Linkify(callbacks=[shorten])))
read <a href="https://example.com/page">example.com/page</a>
bleach’s protocols maps to the Linkify.schemes field, which restricts the explicit URL schemes that autolink,
and bleach’s custom-TLD support maps to Linkify.extra_tlds, on top of a current IANA table you can regenerate where
bleach shipped a frozen list. A bare domain such as example.com still links only when its last label is a known TLD.
Gotchas and pitfalls¶
turbohtml’s safety baseline (
<script>,on*handlers,javascript:URLs) is not configurable, so even a permissiveattributescallable cannot re-admit them, where bleach faithfully kept whatever you allowed.The bleach-compatible
cleanshim does not take acss_sanitizerobject; passing one raisesNotImplementedError. Configure CSS scrubbing throughPolicy.css_propertieson the nativesanitize()instead: it vets a keptstyleattribute and, whenstyleis inPolicy.tags, the<style>element body against the same property allowlist, dropping unsafe declarations while keeping selectors and block nesting.turbohtml leaves an existing
<a>untouched so linkifying is idempotent, where bleach always reprocessed present links. Opt back in with theLinkify.process_existingfield to run the callbacks over author-written anchors too (the callback readsLinkCandidate.existingto branch).Linkify callbacks read
LinkCandidatefields (url,text,attrs), not bleach’s(namespace, name)tuple keys or the"_text"pseudo-key; a straight copy of a bleach callback will not run.