From dominate¶
dominate assembles HTML in Python from the other direction than a parser: you open
a tag as a with block and nest children inside it (or pass them as positional arguments), set attributes with
keyword arguments (cls for class, data_i for data-i), then render the tree to a string. It also
scaffolds whole pages: dominate.document(title=...) emits a doctype with <html>, <head>, and <body>, and
exposes doc.head/doc.body to append into. It is a common way to build reports, emails, and server-rendered pages
in Flask and other Python web stacks without a template language.
turbohtml covers the same ground with the terse turbohtml.build.E builder, but the value it hands back is a real
Element in a parsed tree rather than a bespoke render object, so the whole query, edit, and
serialize surface stays available on what you just built.
turbohtml vs dominate¶
Dimension |
turbohtml |
dominate |
|---|---|---|
Scope |
Full WHATWG parser, serializer, builder, selectors, sanitizer, converters |
HTML generation only, from Python |
Feature breadth |
|
Context-manager nesting, full-page |
Performance |
Builds and serializes in C |
Pure Python |
Typing |
Fully annotated, |
Untyped |
Dependencies |
None (self-contained C extension) |
None (pure Python) |
Maintenance |
Active |
Stable, infrequent releases |
Feature overlap¶
The shared surface ports one-to-one:
div(child, cls="card")->E.div({"class": "card"}, child); attributes are a leading mapping, children follow in the same call.li("text", cls="item", data_i="1")->E.li({"class": "item", "data-i": "1"}, "text"); write the real attribute name as the dict key, with noclsalias ordata_-to-data-rewrite to remember.A string child becomes escaped text in both: dominate wraps it in
text(),Ebuilds aTextnode.A non-identifier tag such as a custom element ->
E("my-card", ...), the call form.A class token list joins on a space:
div(cls="card lg")->E.div({"class": ["card", "lg"]}).
What turbohtml adds¶
The result is an ordinary
Element, soselect(),xpath(),find(),to_markdown(), and the full edit API are available. dominate’s tree offers only a limitedget(tag=..., **kwargs)descendant search, no CSS or XPath.The markup serializes by exactly the WHATWG rules that parse it back, so a fragment round-trips; dominate hand-renders its own string.
Eassembles the fragment in turbohtml’s arena and serializes it in C, about three times faster than dominate.A
Minifylayout (whitespace collapse, optional-tag omission, JS/CSS minify) on top of the compact andIndentlayouts; dominate pretty-prints but does not minify.Full type annotations with a
py.typedmarker.You can
turbohtml.parse()real markup and append anEfragment under it, mixing parsing and building in one tree.
What dominate has that turbohtml does not¶
Context-manager nesting:
with div(): p("hi")auto-collects children created inside the block, andd += childappends in place.Ehas no context-manager or+=form. Workaround: pass children as positional arguments, or build bottom-up andappend().Mutable page accessors:
dominate.document(title=...)hands back an object exposingdoc.head/doc.bodyto append into after the fact.turbohtml.build.document()takes the head and body content up front and returns a finishedDocument; to add more, reach its<body>withselect()andappend().dominate.util.rawinjects unescaped HTML into the tree.Ealways escapes a string into aTextnode. Workaround:turbohtml.parse()the raw markup and append the resulting nodes, since turbohtml is a real parser.Conditional comments:
comment(p("IE"), condition="lt IE 9")renders<!--[if lt IE 9]>...<![endif]-->. turbohtml has aCommentnode but no conditional-comment helper. Workaround: build aCommentnode whose text carries the[if ...]guard and append it.
Performance¶
E is about three times faster than dominate. The same <ul> of rows – a class, a data attribute, and a text
child apiece – built both ways:
build a list |
turbohtml |
|
|---|---|---|
100 rows |
117 µs |
465 µs (4.0x) |
1k rows |
1.41 ms |
5.01 ms (3.6x) |
10k rows |
14.8 ms |
49.3 ms (3.4x) |
The decisive difference is the result type: E hands back a real Element, not a string, so the
call that builds the markup also leaves a tree you can query, edit, and re-serialize().
How to migrate¶
Swap the tag imports (and any document scaffolding) for the single E builder:
# dominate
from dominate.tags import div, h1, p
# turbohtml
from turbohtml.build import E
turbohtml |
|
|---|---|
|
|
|
|
|
|
|
|
|
|
A with-block tree becomes one nested call, attributes leading and children following:
from turbohtml.build import E
card = E.div({"class": "card"}, E.h1("Title"), E.p("body"))
print(card.serialize())
<div class="card"><h1>Title</h1><p>body</p></div>
E("tag", ...) is the call form for a tag that is not a Python identifier (a custom element, say), and a list-valued
attribute joins on a space so a class list reads naturally:
from turbohtml.build import E
print(E("my-card", {"class": ["card", "lg"]}, "hi").serialize())
<my-card class="card lg">hi</my-card>
For a whole page, turbohtml.build.document() is the counterpart to dominate.document(title=...): it emits the
doctype and the <html>/<head>/<body> shell around the content you pass, and hands back a
Document:
from turbohtml.build import E, document
page = document(title="Report", lang="en", body=[E.h1("Sales"), E.p("Up 4%")])
print(page.serialize())
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>Report</title></head><body><h1>Sales</h1><p>Up 4%</p></body></html>
Gotchas and pitfalls¶
Ebuilds a fragment, not a document: there is no implicit<html>/<head>/<body>wrapper and no doctype. Serialize the element you built, or reach forturbohtml.build.document()when you need the full page shell that dominate’sdocument(...)writes.A leading mapping is always read as attributes; to start an element with literal text, pass the string first (
E.p("text", E.b("bold"))).Eescapes strings into text nodes, soE.div("<b>")serializes as<b>. dominate’sraw()had no escaping; to inject real markup,turbohtml.parse()it and append the nodes.turbohtml serializes compact by default, where dominate pretty-prints. Pass
Html(layout=Indent())toserialize()for indented output.Write attribute names as plain dict keys (
{"data-i": "1"}), not dominate’s keyword conventions (cls, trailing underscore for reserved words,data_/aria_underscore-to-dash).The result is an ordinary
Element, so the whole edit and query surface (append,find,select,serialize,to_markdown) is available – the builder only saves the construction boilerplate.