From dominate

dominate latest releasedominate supported Pythonsdominate licensedominate monthly downloadsdominate total downloadsdominate GitHub starsdominate last commit

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

E builder over a real tree: CSS/XPath query, edit, to_markdown, compact/indent/minify layouts

Context-manager nesting, full-page document scaffolding, pretty-print, text/raw/comment helpers

Performance

Builds and serializes in C

Pure Python

Typing

Fully annotated, py.typed

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 no cls alias or data_-to-data- rewrite to remember.

  • A string child becomes escaped text in both: dominate wraps it in text(), E builds a Text node.

  • 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, so select(), xpath(), find(), to_markdown(), and the full edit API are available. dominate’s tree offers only a limited get(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.

  • E assembles the fragment in turbohtml’s arena and serializes it in C, about three times faster than dominate.

  • A Minify layout (whitespace collapse, optional-tag omission, JS/CSS minify) on top of the compact and Indent layouts; dominate pretty-prints but does not minify.

  • Full type annotations with a py.typed marker.

  • You can turbohtml.parse() real markup and append an E fragment 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, and d += child appends in place. E has no context-manager or += form. Workaround: pass children as positional arguments, or build bottom-up and append().

  • Mutable page accessors: dominate.document(title=...) hands back an object exposing doc.head/doc.body to append into after the fact. turbohtml.build.document() takes the head and body content up front and returns a finished Document; to add more, reach its <body> with select() and append().

  • dominate.util.raw injects unescaped HTML into the tree. E always escapes a string into a Text node. 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 a Comment node but no conditional-comment helper. Workaround: build a Comment node 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

dominate

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

dominate

turbohtml

div(p("x"), cls="card") / with div(): blocks

E.div({"class": "card"}, E.p("x")); nest by passing children, not a context manager

li("text", cls="item", data_i="1")

E.li({"class": "item", "data-i": "1"}, "text")

tag(...) for a non-identifier tag name

E("tag", ...), the call form

document(title="T") full page

turbohtml.build.document(title="T")

str(tag) / tag.render(pretty=True)

serialize() (compact) or serialize(Html(layout=Indent()))

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

  • E builds a fragment, not a document: there is no implicit <html>/<head>/<body> wrapper and no doctype. Serialize the element you built, or reach for turbohtml.build.document() when you need the full page shell that dominate’s document(...) 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"))).

  • E escapes strings into text nodes, so E.div("<b>") serializes as &lt;b&gt;. dominate’s raw() 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()) to serialize() 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.