cheat sheet

markupsafe

Package-level reference for markupsafe on PyPI — install, the Markup class, escape semantics, and its role as a Jinja2/Flask/Django dependency.

markupsafe

What it is

markupsafe is a tiny Python library by Armin Ronacher / the Pallets project that provides a Markup string subclass and an escape() function for safely composing HTML and XML. It is the escape primitive underneath Jinja2 (which is used by Flask and indirectly by many Django add-ons), and the autoescape mechanism in those frameworks ultimately routes through this package.

The honest framing: most Python developers never call markupsafe directly — it appears in their dependency tree as a transitive of Jinja2, Flask, or Django, and they interact with it through the template engine's |safe, Markup(...), and autoescape features. This article exists to explain what's there when you see it in pip list and to cover the rare cases where direct use is justified (custom template-like systems, manual HTML assembly inside Python).

Install

bash
pip install markupsafe

Output: (none — exits 0 on success)

bash
uv add markupsafe

Output: resolved + added to pyproject.toml

bash
poetry add markupsafe

Output: updated lockfile + virtualenv install

The wheel includes a compiled C accelerator for escape(); pure-Python fallback is bundled and used when the extension isn't available.

Versioning & Python support

  • Current line is the 2.1.x / 3.x series in 2025-26.
  • Supports Python 3.7+ on older releases; Python 3.8+ on the latest.
  • API is essentially frozen — releases are CVE patches, packaging modernization, and Python-version support bumps.
  • Pin markupsafe>=2,<4 in libraries; applications rarely need to think about the version (it's a transitive dep most of the time).

Package metadata

  • Maintainer: Pallets Projects (David Lord et al.)
  • Project home: github.com/pallets/markupsafe
  • Docs: markupsafe.palletsprojects.com
  • PyPI: pypi.org/project/MarkupSafe
  • License: BSD-3-Clause
  • Governance: Pallets community
  • First released: 2010
  • Downloads: hundreds of millions per month (transitive of Jinja2)

Optional dependencies & extras

markupsafe has zero PyPI extras and zero runtime deps. The compiled C accelerator (_speedups.c) is built from the same wheel — no separate install.

Alternatives

PackageTrade-off
html.escape (stdlib)One function, four characters escaped, no Markup tracking. Fine for one-off use.
bleachHTML sanitizer (parses + filters whitelisted tags). Use when you accept user-supplied HTML.
lxml.html.cleanXML/HTML cleaner; heavier dep but more capable.
Django's django.utils.html.escapeSame idea inside Django; can interop with markupsafe via the safestring protocol.
flask.Markup / jinja2.MarkupThese ARE markupsafe.Markup re-exported; same object.

Common gotchas

  1. Markup("<b>x</b>") is "trust me, this is safe HTML" — the library does NOT validate; you're declaring the bytes are already escaped. Misuse here is the entire XSS attack surface.
  2. escape() returns Markup already. Re-escaping a Markup is a no-op — that's by design and is the reason chained template filters don't double-escape.
  3. Markup is a str subclass. Most string operations preserve the Markup type, but str(m) + "<" returns a str that DOES need escaping again. Don't break out of the Markup lane in the middle of a chain.
  4. Markup("...").format(...) auto-escapes the format argsMarkup("<b>{name}</b>").format(name="<>") produces <b>&lt;&gt;</b>. Useful but surprising.
  5. %-formatting also auto-escapes. Markup("<b>%s</b>") % "<>"Markup("<b>&lt;&gt;</b>"). Same trick.
  6. escape() covers <, >, &, ', " only. It does NOT make a string safe to drop into a <script> tag (JavaScript context) or a URL (use urllib.parse.quote).
  7. HTML-attribute escaping subtlety. escape() is sufficient for double-quoted attribute values; for single-quoted or unquoted attributes the safe play is "always double-quote".
  8. Markup.unescape() reverses the entities — useful for round-trip but DON'T call it on user input expecting safe output.

Real-world recipes

These recipes show the common direct uses — assembling HTML safely, the Markup declared-safe pattern, format-string auto-escape, and the contexts where escape() is NOT enough.

Recipe 1 — Basic escape() on untrusted input.

python
from markupsafe import escape

name = "<script>alert(1)</script>"
safe = escape(name)
print(safe)

Output: &lt;script&gt;alert(1)&lt;/script&gt;Markup instance; safe to drop into HTML.

Recipe 2 — Declare a chunk pre-escaped with Markup(...).

python
from markupsafe import Markup, escape

html = Markup("<p>Hello, ") + escape("<world>") + Markup("</p>")
print(html)

Output: <p>Hello, &lt;world&gt;</p> — manual concat respects the safe/unsafe distinction.

Recipe 3 — Auto-escape via format / %.

python
from markupsafe import Markup

tpl = Markup("<a href='/u/{user}'>{user}</a>")
print(tpl.format(user="<bad>"))

Output: <a href='/u/&lt;bad&gt;'>&lt;bad&gt;</a> — both substitutions escaped despite the outer literal being declared safe. (Single-quoted attribute survives because ' is escaped to &#39;.)

Recipe 4 — JavaScript context: escape() is NOT enough.

python
import json
from markupsafe import Markup

user_data = {"name": "</script><script>alert(1)</script>"}

# WRONG — escape() doesn't protect inside <script>
# bad = Markup(f"<script>var u = '{user_data['name']}';</script>")

# RIGHT — JSON-encode then drop into the script tag
safe_js = Markup("<script>var u = {json};</script>").format(
    json=Markup(json.dumps(user_data))
)
print(safe_js)

Output: <script>var u = {"name": "</script><script>alert(1)</script>"};</script> — uh oh, JSON alone isn't enough either. The fully-safe pattern is json.dumps(...).replace("</", "<\\/") before insertion. Lesson: don't hand-roll script-tag injection; render JSON to a <script type="application/json"> and parse from JS.

Recipe 5 — HTML-attribute escaping in a custom context.

python
from markupsafe import escape, Markup

def link(href: str, text: str) -> Markup:
    # Always double-quote; escape() handles &quot; properly.
    return Markup('<a href="{}">{}</a>').format(href, text)

print(link("/u?x=1&y=2", "<click me>"))

Output: <a href="/u?x=1&amp;y=2">&lt;click me&gt;</a> — both ampersands and angle brackets escaped.

Recipe 6 — CSP-friendly HTML emission.

python
from markupsafe import Markup, escape

# Avoid inline styles/scripts where possible.
# When you must emit them, escape the content and prefer external assets.
fragment = Markup('<div class="{cls}">{body}</div>').format(
    cls="card",
    body=escape("<user input>"),
)
print(fragment)

Output: <div class="card">&lt;user input&gt;</div> — pairs naturally with Content-Security-Policy: default-src 'self' because no inline JS is generated.

Production deployment notes

  • Use through Jinja2 or Django, not directly. The framework's autoescape + template inheritance is far safer than ad-hoc Markup use.
  • If you must construct HTML in Python, wrap your assembly function so it returns Markup and accepts only escape()-d substrings.
  • Configure CSP. Markupsafe addresses one piece of XSS; a strong Content-Security-Policy header (with nonces, script-src 'self') addresses the rest.
  • Don't disable autoescape globally. Jinja2's autoescape=True (the Flask default) is the safe baseline; override per-template only when emitting trusted content.

Performance tuning

  • C accelerator is enabled by default in wheels. Confirm with python -c "from markupsafe import _speedups; print(_speedups)" — if that imports, you have the fast path.
  • escape() is microsecond-level. Don't pre-escape and cache unless you've measured a bottleneck.
  • Markup allocations cost — repeated Markup(a) + Markup(b) in hot loops creates intermediate strings. Use a list + Markup("").join(...).
  • Templates are usually the bottleneck, not the escape call. Profile before optimizing this.

Version migration guide

  • < 2.0 — Python 2 era; ancient. Upgrade.
  • 2.0 → 2.1 — minor API tightening; Python 3.6 dropped.
  • 2.1 → 3.0 — Python 3.7 dropped; modern packaging (pyproject.toml).
  • No imminent breaking changes expected. The API is essentially frozen.
python
# Pre-2.0 (Python 2 era)
from markupsafe import Markup, escape, soft_str  # also `soft_unicode` (removed)

# 2.1+
from markupsafe import Markup, escape, soft_str  # soft_unicode gone since 2.0

Output: same code; soft_unicode was the Python-2 name retained as an alias and removed.

Security considerations

markupsafe is the canonical Python escape function for HTML; the security model is small but easy to misuse.

  • Markup(...) is a YOU-ARE-VOUCHING gesture. Wrapping user input in Markup() defeats the entire purpose. Searches for Markup(.*request.*) in code review are valuable.
  • Context matters more than escape strength. Same string can be safe in <body> and dangerous in <script>, <style>, or href=. escape() handles the <body> and <attr> contexts well; you need different escaping for JS/CSS/URL contexts.
  • CVE history: small but non-zero. The 2.1.x line had a fix for a regex denial-of-service in striptags(). Stay on the latest patch.
  • Don't roll your own escape(). Even the four-character escape requires getting & ordering right (escape & FIRST, then <, >, ").
  • Pair with CSP. Content-Security-Policy + markupsafe + framework autoescape is the defense in depth.

Testing & CI integration

  • Unit-test escape() round-trips with hypothesis over Unicode strings — useful for catching surrogateescape bugs in edge cases.
  • For Jinja2/Flask apps, test autoescape is on by default in your Environment config.
  • Use a static analyzer (bandit) with rule B701 to flag jinja2.Environment(autoescape=False).
python
from markupsafe import Markup, escape

def test_escape_basic():
    assert escape("<a>") == "&lt;a&gt;"

def test_markup_format_escapes_args():
    out = Markup("<b>{}</b>").format("<x>")
    assert str(out) == "<b>&lt;x&gt;</b>"

Output: both tests pass; documents the safe-formatting behavior.

Ecosystem integrations

  • Jinja2 — uses Markup and escape for autoescape and |safe. The primary consumer.
  • Flask — re-exports Markup, escape from flask for back-compat.
  • Django — has its own mark_safe / SafeString, but interoperates with markupsafe via the __html__ protocol.
  • pallets-eco — sister projects in the Pallets ecosystem (Quart, Quart-Babel, etc.) all depend on markupsafe transitively.
  • werkzeug — uses markupsafe in error pages and debugger.

Compatibility matrix

Pythonmarkupsafe lineNotes
3.72.1 and earlierDropped in 3.x.
3.82.1.x / 3.xFloor for current.
3.92.1.x / 3.xSupported.
3.102.1.x / 3.xSupported.
3.112.1.x / 3.xSupported.
3.122.1.x / 3.xSupported.
3.133.xGIL builds; free-threaded experimental.

Troubleshooting common errors

Error / SymptomLikely causeFix
Output contains literal &lt; instead of < in templatesWrapping escape() output in another escape() or |e filterescape is idempotent — usually fine. If double-encoding appears, look for Markup(escape(...)) patterns.
XSS in browser despite |safe not usedBuilding HTML with f"<div>{x}</div>" without escapingUse Jinja2 templates with autoescape, or Markup("...").format(x).
TypeError: unsupported operand type(s) for +: 'Markup' and 'NoneType'Concatenating Markup with NoneGuard with or "" or Markup("") before concat.
Slow renderPython fallback used (no C extension)Reinstall on a supported platform to get the speedups wheel.
AttributeError: 'str' object has no attribute '__html__' from Django interopMixing markupsafe Markup with Django's SafeTextBoth implement __html__(); check which framework's mark_safe you're using.
Curly braces in literal HTML break Markup.format()format interprets {...} as substitutionUse Markup("...{{escaped}}...") to emit literal braces.

When NOT to use this

  • You're using Jinja2, Flask, Django, or any framework. Already pulled in transitively; use the framework's filters and autoescape.
  • One-off escape of trusted content. stdlib html.escape is fine.
  • HTML sanitization (accepting user-supplied HTML). Use bleach or lxml.html.cleanmarkupsafe only escapes, doesn't sanitize tags.
  • Non-HTML contexts (JS, CSS, URL). Use json.dumps, CSS-specific escaping, or urllib.parse.quote.
  • You see it as a dep but don't use it. Don't add it to your direct deps unless you actually import markupsafe somewhere.

Worked example: build a minimal safe HTML builder

A direct-use scenario: you're generating fragments of HTML in Python (email body, a non-template-engine context, a one-off report) and want the same correctness guarantees Jinja2 gives you.

Step 1 — wrap the primitives.

python
from markupsafe import Markup, escape

def el(tag: str, *children, **attrs) -> Markup:
    attr_str = "".join(
        Markup(' {k}="{v}"').format(k=k.rstrip("_"), v=v) for k, v in attrs.items()
    )
    body = Markup("").join(c if isinstance(c, Markup) else escape(c) for c in children)
    return Markup("<{tag}{attrs}>{body}</{tag}>").format(tag=tag, attrs=Markup(attr_str), body=body)

Output: el("div", "<bad>", class_="card")<div class="card">&lt;bad&gt;</div>. The el helper escapes text children and attribute values; it preserves Markup children verbatim.

Step 2 — compose pieces.

python
def user_card(name: str, bio_html: Markup) -> Markup:
    return el(
        "article",
        el("h2", name),
        el("div", bio_html, class_="bio"),
        class_="user-card",
    )

# bio_html is trusted (came from a Markdown-renderer with sanitization).
trusted_bio = Markup("<p>Software engineer based in <em>San Francisco</em>.</p>")
print(user_card("Alice Dev", trusted_bio))

Output: the name is escaped (it came in as untrusted str); the bio passes through (already Markup). The escape boundary is at every type cast between str and Markup.

Step 3 — handle URL contexts properly.

python
from urllib.parse import quote

def link(href_path: str, query: dict, text: str) -> Markup:
    qs = "&".join(f"{quote(k)}={quote(str(v))}" for k, v in query.items())
    return el("a", text, href=f"{href_path}?{qs}" if qs else href_path)

print(link("/u", {"name": "<bad>", "id": 1}, "click me"))

Output: <a href="/u?name=%3Cbad%3E&id=1">click me</a> — URL encoding via urllib.parse.quote, HTML escaping via markupsafe. They're DIFFERENT escapes for DIFFERENT contexts.

Step 4 — JSON inside a script tag, done safely.

python
import json
from markupsafe import Markup

def script_json(data) -> Markup:
    encoded = json.dumps(data).replace("</", "<\\/")
    return Markup('<script type="application/json" id="data">{}</script>').format(
        Markup(encoded)
    )

print(script_json({"name": "</script><script>alert(1)</script>"}))

Output: the closing </ sequences are split so the embedded JSON can't break out of the <script> tag. Parse from JS with JSON.parse(document.getElementById("data").textContent).

FAQ

Q: Does escape() work on bytes? A: No — it expects str. Decode first (b.decode("utf-8")).

Q: How do I emit raw HTML inside a Jinja template? A: Either {{ value|safe }} or pass a Markup(value) from the view. |safe is the friendlier syntax.

Q: My markupsafe import is slow — why? A: It shouldn't be. If your import time has spiked, you may have inadvertently pulled in the larger werkzeug or flask package via a transitive. Run python -X importtime -c "import markupsafe" to confirm.

Q: Can I disable autoescape per-block in Jinja? A: Yes — {% autoescape false %}...{% endautoescape %}. Use sparingly; this is where XSS lives.

Q: How do I escape for SVG context? A: SVG is XML; the same HTML-style escapes apply. Be extra careful with <script> inside SVG (same JS context as HTML) and with attribute values in xlink:href (URL context).

Q: Is markupsafe thread-safe? A: Yes — escape() is a pure function; Markup is immutable. Safe to use from any thread.

Q: Does it handle Unicode correctly? A: Yes — strs are Unicode all the way through; the C accelerator handles UTF-8 internally. No surrogate gotchas in normal use.

The __html__ protocol

markupsafe (and Django's mark_safe, and Jinja2's Markup) all honor a small duck-typing protocol: any object with a __html__() method is treated as already-safe. This lets you author custom classes that participate in autoescape without depending on markupsafe directly.

python
class Badge:
    def __init__(self, label: str, kind: str = "info"):
        self.label = label
        self.kind = kind

    def __html__(self) -> str:
        from markupsafe import escape
        return f'<span class="badge badge-{escape(self.kind)}">{escape(self.label)}</span>'

# In any template engine that honors __html__:
b = Badge("<new>", kind="success")
# Jinja: {{ b }} renders the HTML; b's __html__ owns the escaping decisions.

Output: <span class="badge badge-success">&lt;new&gt;</span> when rendered by any __html__-aware engine. Your class controls escape semantics; the engine respects it without coupling to markupsafe.

This is how cross-framework safe-string interop works (Django ↔ Jinja ↔ Quart ↔ etc.). Lean on it instead of hard-coding Markup(...) calls in framework-agnostic code.

Reference table: which escape for which context

ContextRight escapeWrong choices
HTML body textescape() / markupsafe.Markuphtml.escape is fine; raw f-strings are NOT
HTML attribute value (double-quoted)escape()urllib.parse.quote (wrong; URL escape)
HTML attribute value (single-quoted)escape() — escapes both ' and "Custom replacements; easy to miss '
URL query parameterurllib.parse.quote (then escape if dropping in HTML)escape() alone (won't handle %, &)
URL path segmenturllib.parse.quote(s, safe="")escape()
Inside <script> JS literaljson.dumps(...) + replace("</", "<\\/")escape() — does nothing for JS
Inside <style> CSSCSS-specific escaping (rare; usually emit external stylesheets)escape() — does nothing for CSS
Inside an HTML commentStrip --> from input; escape() doesn't help hereTrusting escape()
Inside a srcdoc attributeBoth URL- and HTML-escape (double-escape)One layer only

The big mental model: markupsafe.escape solves one of these contexts (HTML body + attribute). For everything else, the right tool lives elsewhere. The most common XSS bug in real code is using the wrong escape for the context — usually JS-in-HTML or URL-in-href.

See also

  • Concept: API — designing safe APIs that return Markup vs str