mrkeyoor.com_
Thu 06 Aug 13:55 UTC
PyPIUtilsupdated 06 Aug 2026

nh3

nh3 takes a string of untrusted HTML and gives you back a string of HTML that is safe to put on a page. It is a thin Python binding over ammonia, a Rust crate that parses the input with a real HTML5 parser and then rebuilds the output from an allowlist of tags, attributes, and URL schemes. Anything not on the list is dropped. Because it re-serializes from a parse tree rather than pattern-matching on the text, the classic bypasses that beat regex-based filters do not apply: malformed nesting, weird casing, null bytes, and broken quoting all get normalized before the allowlist runs. The API is four functions and one class. nh3.clean(html) sanitizes with sensible defaults. nh3.escape(html) escapes everything so the input renders as literal text. nh3.is_html(s) tells you whether a string contains markup at all. nh3.Cleaner(...) is the same as clean but with the configuration compiled once so you can reuse it. There are no Python dependencies at all; the shipped wheels contain compiled Rust.

Verdict

The right default for sanitizing user HTML in Python now that bleach is deprecated: a real HTML5 parser, an allowlist rebuild, and roughly twenty times bleach's throughput for a two-line change. Check ALLOWED_TAGS against your own threat model first, because the defaults are tuned for rich text rather than for a comment box.

API stability4/5Still on 0.x and the version number should be taken seriously, but clean(), clean_text(), and is_html() have not changed shape since the first releases; 0.3.x has been additive, bringing url_relative, id_prefix, filter_style_properties, and the escape() alias for clean_text without breaking existing calls
Docs3/5The Read the Docs page is one page, but it is a good one: every Cleaner parameter documented with a runnable doctest, including the awkward interaction between tag_attribute_values and attributes. What is missing is anything above that level, so there is no migration guide from bleach, no discussion of what the default allowlist is for, and the README is a benchmark and an install line
Maintenance3/5Pushed 2026-07-28 with 4 open issues out of 5 open issues and PRs and regular 0.3.x releases through 2026, which is healthy for the size. The reservation is structural: one maintainer, 389 stars, and 12.6M weekly downloads is a wide gap between usage and bus factor, softened by the fact that the sanitizing logic itself is ammonia's
Ecosystem4/5About 12.6M downloads a week and it is what packages moved to after bleach was deprecated, including readme_renderer, which is why PyPI itself is downstream of it. There is no plugin layer and nothing built on top; it is a single function most projects call in one place

Use it if

  • You accept HTML from users, whether that is a comment box, a rich text editor, or rendered Markdown, and you have to put it back on a page inside somebody else's session
  • You are moving off bleach, which Mozilla deprecated and stopped maintaining in 2023: nh3 covers the same clean() job and the README measures 138 microseconds against bleach's 2.85 milliseconds on the same input
  • Sanitizing is on a hot path, such as rendering a feed of hundreds of user-authored items per request, where a pure Python filter shows up in your profile
  • You need fine-grained policy rather than on or off: per-tag attribute allowlists, per-tag allowed class names, allowed values for a specific attribute, a style property allowlist, and a callback that can rewrite or drop any attribute
  • You want no dependency tree. nh3 declares zero Python requirements, which matters in a base image or a library that other people install
Skip it if

Setup reality

pip install nh3 and there is nothing to configure: no Python dependencies, no compiler on the common platforms, abi3 wheels covering CPython 3.8 and up plus PyPy for Linux, macOS, and Windows on x86_64 and arm64. The friction is elsewhere. First, the default policy is deliberately permissive because it is meant for user-authored rich text: a, img, table, blockquote, code, and around sixty other tags are allowed out of the box, and if your comment box should only accept bold and links then you must pass tags={'b','a'} yourself. Read nh3.ALLOWED_TAGS before you decide the defaults match your threat model. Second, nh3.clean(html, tags=..., attributes=...) recompiles the policy on every single call, so a loop over a thousand comments builds the allowlist a thousand times; build a Cleaner once at module level instead. Third, when you customize ALLOWED_ATTRIBUTES you have to deepcopy it, because it is a plain dict of sets and mutating it in place changes the default policy for the whole process. Fourth, link_rel defaults to 'noopener noreferrer' and is force-added to every anchor, which surprises people diffing expected output in tests, and if you want to allow a rel attribute through you must also set link_rel=None or the two conflict. Finally, this cleans HTML only. Sanitizing user Markdown means rendering it to HTML first and cleaning the result, never the other way around.

Patterns

Sanitize a fragment with the built-in policyclean-with-defaults

import nh3

nh3.clean("<unknown>hi")
# 'hi'

nh3.clean("<b><img src='' onerror='alert(1)'>XSS?</b>")
# '<b><img src="">XSS?</b>'

nh3.clean('<a href="https://example.com">link</a>')
# '<a href="https://example.com" rel="noopener noreferrer">link</a>'

Unknown tags are unwrapped and their text kept; event handler attributes are dropped entirely. Note the rel that appeared on the anchor: link_rel defaults to 'noopener noreferrer' and is added to every link, which is correct behaviour and a surprise the first time a test asserts on exact output. The defaults allow roughly sixty tags including img and table, so this is a rich-text policy, not a minimal one.

Allow only the tags you actually wantrestrict-tags

import nh3

ALLOWED = {"b", "i", "em", "strong", "a", "code", "p", "br"}

nh3.clean("<b><a href='https://example.com'>Hello</a></b>", tags=ALLOWED)
# '<b><a href="https://example.com" rel="noopener noreferrer">Hello</a></b>'

# start from the default set instead of listing everything
no_images = nh3.ALLOWED_TAGS - {"img", "table", "thead", "tbody", "tr", "td", "th"}
nh3.clean(user_html, tags=no_images)

tags takes a set, not a list, and passing a list is the most common porting mistake from bleach. A tag that is not allowed is unwrapped, so the text inside survives: <script>alert(1)</script> becomes the literal text alert(1) on the page, which is inert but ugly. If you want the contents gone too, that is clean_content_tags, below.

Remove the element and everything inside itstrip-tag-contents

import nh3

nh3.clean("<script>alert('xss')</script>safe")
# "alert('xss')safe"   <- text survives

nh3.clean("<script>alert('xss')</script>safe", clean_content_tags={"script"})
# 'safe'

nh3.clean(
    "<style>body{display:none}</style><p>hi</p>",
    clean_content_tags={"script", "style"},
)
# '<p>hi</p>'

This is the difference between an unwrapped tag and a deleted subtree, and it matters for script and style, whose text content is noise once the tag is gone. clean_content_tags must be disjoint from the allowed tag set, including the default one when you omit tags, and overlapping them raises ValueError rather than picking a winner.

Control which attributes survive on which tagsper-tag-attributes

import nh3

nh3.clean(
    '<a href="/" id="link" onclick="x()">click</a>',
    tags={"a", "img"},
    attributes={
        "*": {"id", "title"},          # allowed on any tag
        "a": {"href"},
        "img": {"src", "alt", "width", "height"},
    },
)
# '<a href="/" id="link" rel="noopener noreferrer">click</a>'

# strip every attribute
nh3.clean(user_html, attributes={})

The '*' key is a union with the per-tag entry, not a fallback, so listing href under '*' allows it everywhere including on tags where it means nothing. Passing attributes={} is the strictest useful setting and is what escape(tags=...) does internally. Attributes are matched by name only here; if you need to constrain the value, use tag_attribute_values.

Compile the policy once for hot pathsreusable-cleaner

import nh3

# module level: build the allowlist a single time
COMMENT_CLEANER = nh3.Cleaner(
    tags={"b", "i", "em", "strong", "a", "code", "pre", "p", "br", "blockquote"},
    attributes={"a": {"href", "title"}},
    url_schemes={"http", "https", "mailto"},
    strip_comments=True,
)

def render_comment(raw: str) -> str:
    return COMMENT_CLEANER.clean(raw)

nh3.clean(html, tags=...) rebuilds the policy on every call, so a loop over a thousand comments constructs the allowlist a thousand times and throws away most of the speed advantage over bleach. Cleaner is the same options with the construction hoisted out. It has no mutable per-call state, so a module-level instance is fine to share across threads and requests.

Rewrite or drop attributes with a callbackrewrite-attributes

from copy import deepcopy
import nh3

attributes = deepcopy(nh3.ALLOWED_ATTRIBUTES)   # deepcopy, not copy
attributes["a"].add("class")

def attribute_filter(tag: str, attr: str, value: str) -> str | None:
    if tag == "a" and attr == "class":
        return "mention" if "mention" in value.split(" ") else None
    if tag == "img" and attr == "src" and not value.startswith("https://cdn."):
        return None                    # drop the attribute
    return value                       # keep unchanged

nh3.clean("<a class='mention unwanted'>@foo</a>",
          attributes=attributes, attribute_filter=attribute_filter)
# '<a class="mention" rel="noopener noreferrer">@foo</a>'

Return None to remove the attribute, or a string to replace its value. The callback only runs for attributes that already passed the allowlist, so it narrows policy and never widens it. deepcopy matters: ALLOWED_ATTRIBUTES is a dict of sets and attributes['a'].add(...) on the original mutates the default policy for every other caller in the process.

Block javascript: URLs and control relurl-schemes-and-rel

from copy import deepcopy
import nh3

nh3.ALLOWED_URL_SCHEMES
# {'http', 'https', 'mailto', 'tel', 'ftp', ...}

web_only = {"http", "https", "mailto"}
nh3.clean('<a href="javascript:alert(1)">x</a>', url_schemes=web_only)
# '<a rel="noopener noreferrer">x</a>'   <- href removed, text kept

# keep an author-supplied rel instead of forcing one
attributes = deepcopy(nh3.ALLOWED_ATTRIBUTES)
attributes["a"].add("rel")
nh3.clean("<a href='https://tag.example' rel='tag'>#tag</a>",
          link_rel=None, attributes=attributes)
# '<a href="https://tag.example" rel="tag">#tag</a>'

A disallowed scheme drops the href and leaves the anchor, so the link becomes dead rather than disappearing. data: is not in the default set, which is what stops data:text/html payloads. If you allow rel through attributes you must also pass link_rel=None, otherwise the forced value and the author value collide; and dropping noopener on user links reintroduces the window.opener attack you were defending against.

Deny or rewrite relative URLsrelative-urls

import nh3

nh3.clean('<a href="/foo">x</a>', url_relative="deny")
# '<a rel="noopener noreferrer">x</a>'

nh3.clean('<a href="/foo">x</a>',
          url_relative=("rewrite_with_base", "https://example.com"))
# '<a href="https://example.com/foo" rel="noopener noreferrer">x</a>'

nh3.clean('<img src="/a.png">',
          url_relative=lambda url: f"https://cdn.example.com{url}")

The default is pass_through, which means user content can link to your own routes with paths of its own choosing; that is fine for a wiki and wrong for an email digest where a relative URL resolves against the mail client. A callable that raises or returns a non-string strips the URL and reports the error through sys.unraisablehook, so a broken rewriter fails quietly rather than aborting the request.

Allow specific CSS classes and style propertiesclasses-and-styles

import nh3

# per-tag class allowlist (do not also allow the class attribute)
nh3.clean('<span class="highlight bold">text</span>',
          allowed_classes={"span": {"highlight"}})
# '<span class="highlight">text</span>'

# allow style, then restrict which properties may appear
nh3.clean('<span style="color: red; position: fixed">text</span>',
          attributes={"span": {"style"}},
          filter_style_properties={"color", "text-align"})
# '<span style="color:red">text</span>'

# namespace ids so user content cannot collide with your page
nh3.clean('<b id="x">hi</b>', attributes={"b": {"id"}}, id_prefix="user-content-")

Allowing the raw style attribute with no filter_style_properties lets user content set position:fixed and cover your page with a clickjacking overlay, so the two go together or not at all. allowed_classes replaces the class attribute check, so listing class in attributes as well makes the allowlist useless. id_prefix is the same defence GitHub applies to rendered README anchors.

When the input is text, escape itescape-instead-of-clean

import nh3

nh3.escape('Robert"); abuse();//')
# 'Robert&quot;);&#32;abuse();&#47;&#47;'

nh3.escape("<span>hello <mention>moto</mention>!</span>", tags={"mention"})
# 'hello <mention>moto</mention>!'

nh3.clean_text("same function, older name")

escape() and clean_text() are the same function; escape is the preferred name because it escapes input rather than sanitizing markup. It is stricter than the standard library html.escape, which only handles the five characters, whereas this encodes everything with meaning to the parser. Use it when a field is supposed to be plain text: escaping is a smaller decision surface than an allowlist, so prefer it whenever HTML is not actually wanted.

Check whether a string contains markupdetect-html

import nh3

nh3.is_html("plain text")     # False
nh3.is_html("<p>html!</p>")   # True

# a plausible use: only pay for sanitizing when there is markup
def render(field: str) -> str:
    return nh3.clean(field) if nh3.is_html(field) else nh3.escape(field)

This parses the whole string, so it is not a cheap pre-check; on short fields calling clean directly is usually faster than testing first. It is also loose about what counts as HTML, returning True for things like <g> and for Rust turbofish syntax such as Vec::<u8>::new(), so do not use it to decide whether a user meant to write markup.

Port a bleach call, including the part that does not portmigrate-from-bleach

# before
import bleach
bleach.clean(text, tags=["b", "a"], attributes={"a": ["href"]}, strip=True)
bleach.linkify(text)

# after
import nh3
nh3.clean(text, tags={"b", "a"}, attributes={"a": {"href"}})
# linkify: no equivalent, do it before cleaning
import re
URL = re.compile(r"(?<!\"|')(https?://[^\s<]+)")
linked = URL.sub(r'<a href="\1">\1</a>', nh3.escape(text))
nh3.clean(linked, tags={"a"}, attributes={"a": {"href"}})

Lists become sets, strip= has no counterpart because nh3 always unwraps rather than escaping disallowed tags, and styles= is now filter_style_properties. The linkify gap is the real work: escape the text first so the regex cannot match inside markup a user wrote, then insert your own anchors, then clean the result so a crafted URL cannot break out of the href you built.

Alternatives

PackageRegistryPick it when
bleachPyPIYou need linkify or you are maintaining old code and cannot change call sites, accepting that the project is deprecated and no longer receiving fixes
lxml-html-cleanPyPIYou are already using lxml to parse the document and want cleaning from the same tree instead of a second parse
html-sanitizerPyPIYou want opinionated normalization of editor output, such as merging tags and unwrapping empty elements, not just an allowlist filter