nh3 review
nh3 0.3.7 turns untrusted HTML fragments into policy-limited HTML through Rust ammonia bindings. It parses markup, drops forbidden tags and attributes, checks link schemes, optionally removes entire dangerous subtrees, and returns serialized text. A reusable `Cleaner` avoids rebuilding the policy for each call. Version 0.3.7 adds ID prefixing, rejects conflicting broad and value-specific attribute rules, and moves from ammonia 4.1.2 to 4.1.4. Our measurements cover 0.3.6, the immediately preceding native wheel, rather than this new release.
Our nh3 0.3.6 install took 0.3 seconds, occupied 3 MB, imported in 0.01 seconds, and had no audit findings; current 0.3.7 was published afterward. Choose nh3 for fragment sanitation with a tested allowlist, but choose another parser for DOM work or linkification.
We installed it
| Install | ✓ · 0.3s | 1 package on disk · 3 MB |
| Import | ✓ | import nh3 in 0.01s · compiled extensions · py.typed · requires Python >=3.8 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does nh3 install cleanly?
Yes. In a fresh container with an empty cache, pip install nh3 finished in 0.3s, leaving 1 package and 3 MB on disk. pip-audit reported no known vulnerabilities.
What does nh3 need to run?
Python >=3.8, and a platform wheel with compiled extensions. In our run import nh3 succeeded in 0.01s, and the package ships py.typed for type checkers.
nh3 or bleach: which should you use?
bleach: Keep it during a compatibility migration that still depends on Bleach signatures or linkify, despite deprecation. Our nh3 0.3.6 install took 0.3 seconds, occupied 3 MB, imported in 0.01 seconds, and had no audit findings; current 0.3.7 was published afterward.
When should you not use nh3?
Plain URLs must become anchor tags; nh3 offers sanitation and no linkify API.
Use it if
- Rendered user Markdown or rich-text editor output must be cleaned before it reaches a browser.
- The application needs a precise allowlist for tags, attributes, schemes, classes, or style properties.
- One sanitizing policy handles many fragments and can live in a shared Cleaner instance.
- A Bleach migration needs HTML cleaning but does not depend on Bleach's separate linkify operation.
- Plain URLs must become anchor tags; nh3 offers sanitation and no linkify API.
- Code needs DOM selection or node mutation; nh3 exposes cleaned strings rather than a traversable tree.
- A full document must retain its html, head, and body structure; the API is designed around fragments.
- The target has no compatible wheel and cannot build Rust extensions; our package contained native `.so` files and no pure-Python fallback.
- Large SVG or MathML vocabularies must pass through; broad namespace allowances can erase the safety value of a narrow HTML policy.
Setup reality
We installed nh3 0.3.6 in our lab before 0.3.7 reached PyPI. That Python 3.12 Bookworm install took 0.3 seconds, produced 1 package using 3 MB, and imported in 0.01 seconds. The measured wheel had 0 direct dependencies, required Python 3.8 or newer, included compiled .so files and py.typed, and carried the MIT license. pip-audit reported 0 known vulnerabilities. The 0.3.7 artifact may differ, so those numbers belong only to 0.3.6.
No service account or configuration file is needed. Policy design is the setup. Default cleaning keeps a fairly broad rich-text vocabulary and adds rel="noopener noreferrer" to links. A compact comments field should normally pass smaller tag and attribute sets. Forbidden tags are unwrapped by default, leaving their text. Put script and style in clean_content_tags when their contents must disappear with the element.
Create one Cleaner when rules repeat; passing option sets to clean reconstructs policy for every fragment. Value filters must not conflict with an attribute rule that already accepts every value. Version 0.3.7 turns that ineffective tag_attribute_values configuration into a ValueError. The same principle applies to class filtering: do not broadly allow class and then expect allowed_classes to be the boundary.
Review links separately from elements. Limit url_schemes, choose whether relative URLs survive or receive a base URL, and keep a safe link_rel unless another trusted component supplies it. The new id_prefix converts retained IDs such as billing into a host-specific namespace, avoiding collisions with page controls. For Markdown, render first and sanitize the resulting HTML; cleaning Markdown source cannot inspect raw markup emitted or preserved by the renderer.
Patterns
Remove disallowed attributes with the stock policy clean-default
import nh3
raw = '<p>Hello <img src="x" onerror="alert(1)"></p>'
safe = nh3.clean(raw)
print(safe)The event handler is removed, while allowed rich-text markup remains; tighten defaults for a small comment field.
Define a compact formatting allowlist limit-markup
safe = nh3.clean(raw_html,
tags={"p", "br", "strong", "em", "code", "a"},
attributes={"a": {"href", "title"}},
url_schemes={"http", "https", "mailto"})Disallowed wrappers normally vanish while their text remains, and each accepted URL still passes the scheme rule.
Discard script and style contents drop-subtree
result = nh3.clean('<script>alert(1)</script><p>safe</p>', clean_content_tags={"script", "style"})
assert result == '<p>safe</p>'Without `clean_content_tags`, removing a forbidden element can preserve its text content.
Keep one Cleaner for repeated fragments reuse-policy
COMMENT_CLEANER = nh3.Cleaner(tags={"p", "strong", "em", "a"}, attributes={"a": {"href"}}, url_schemes={"https"})
def clean_comment(raw: str) -> str:
return COMMENT_CLEANER.clean(raw)A Cleaner retains parsed settings, avoiding per-call policy construction when all comments share the same rules.
Accept only selected role values restrict-values
safe = nh3.clean('<div role="banner">notice</div>', tags={"div"}, attributes={}, tag_attribute_values={"div": {"role": {"alert", "status"}}})Version 0.3.7 raises if `attributes` also broadly admits `role`, because that would bypass this value set.
Namespace IDs retained from user HTML prefix-ids
safe = nh3.clean('<h2 id="billing">Billing</h2>', tags={"h2"}, attributes={"h2": {"id"}}, id_prefix="user-content-")`id_prefix` is new in 0.3.7; the element and ID attribute still must pass their allowlists.
Resolve relative URLs against a trusted host rewrite-relative-link
safe = nh3.clean('<a href="/help">Help</a>', tags={"a"}, attributes={"a": {"href"}}, url_relative=("rewrite_with_base", "https://docs.example.com"))The default can retain relative paths, which may resolve against the wrong site when HTML moves into email or another origin.
Clean HTML after Markdown rendering sanitize-markdown
import markdown, nh3
raw_html = markdown.markdown(user_markdown)
safe_html = nh3.clean(raw_html, tags={"p", "strong", "em", "a", "code", "pre"}, attributes={"a": {"href"}})Rendering comes first because a Markdown engine can emit or preserve raw HTML that the sanitizer must inspect.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| bleach | PyPI | Keep it during a compatibility migration that still depends on Bleach signatures or linkify, despite deprecation. |
| lxml-html-clean | PyPI | Use it when cleaning is part of an existing lxml document-tree workflow. |
| html-sanitizer | PyPI | Use it when its opinionated editor-output normalization matches the stored content. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

