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
pip install markupsafe
Output: (none — exits 0 on success)
uv add markupsafe
Output: resolved + added to pyproject.toml
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.xseries 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,<4in 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
| Package | Trade-off |
|---|---|
html.escape (stdlib) | One function, four characters escaped, no Markup tracking. Fine for one-off use. |
bleach | HTML sanitizer (parses + filters whitelisted tags). Use when you accept user-supplied HTML. |
lxml.html.clean | XML/HTML cleaner; heavier dep but more capable. |
Django's django.utils.html.escape | Same idea inside Django; can interop with markupsafe via the safestring protocol. |
flask.Markup / jinja2.Markup | These ARE markupsafe.Markup re-exported; same object. |
Common gotchas
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.escape()returnsMarkupalready. Re-escaping aMarkupis a no-op — that's by design and is the reason chained template filters don't double-escape.Markupis a str subclass. Most string operations preserve theMarkuptype, butstr(m) + "<"returns astrthat DOES need escaping again. Don't break out of theMarkuplane in the middle of a chain.Markup("...").format(...)auto-escapes the format args —Markup("<b>{name}</b>").format(name="<>")produces<b><></b>. Useful but surprising.%-formatting also auto-escapes.Markup("<b>%s</b>") % "<>"→Markup("<b><></b>"). Same trick.escape()covers<,>,&,',"only. It does NOT make a string safe to drop into a<script>tag (JavaScript context) or a URL (useurllib.parse.quote).- 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". 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.
from markupsafe import escape
name = "<script>alert(1)</script>"
safe = escape(name)
print(safe)
Output: <script>alert(1)</script> — Markup instance; safe to drop into HTML.
Recipe 2 — Declare a chunk pre-escaped with Markup(...).
from markupsafe import Markup, escape
html = Markup("<p>Hello, ") + escape("<world>") + Markup("</p>")
print(html)
Output: <p>Hello, <world></p> — manual concat respects the safe/unsafe distinction.
Recipe 3 — Auto-escape via format / %.
from markupsafe import Markup
tpl = Markup("<a href='/u/{user}'>{user}</a>")
print(tpl.format(user="<bad>"))
Output: <a href='/u/<bad>'><bad></a> — both substitutions escaped despite the outer literal being declared safe. (Single-quoted attribute survives because ' is escaped to '.)
Recipe 4 — JavaScript context: escape() is NOT enough.
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.
from markupsafe import escape, Markup
def link(href: str, text: str) -> Markup:
# Always double-quote; escape() handles " properly.
return Markup('<a href="{}">{}</a>').format(href, text)
print(link("/u?x=1&y=2", "<click me>"))
Output: <a href="/u?x=1&y=2"><click me></a> — both ampersands and angle brackets escaped.
Recipe 6 — CSP-friendly HTML emission.
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"><user input></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
Markupuse. - If you must construct HTML in Python, wrap your assembly function so it returns
Markupand accepts onlyescape()-d substrings. - Configure CSP. Markupsafe addresses one piece of XSS; a strong
Content-Security-Policyheader (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.Markupallocations cost — repeatedMarkup(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.
# 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 inMarkup()defeats the entire purpose. Searches forMarkup(.*request.*)in code review are valuable.- Context matters more than escape strength. Same string can be safe in
<body>and dangerous in<script>,<style>, orhref=.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
hypothesisover Unicode strings — useful for catchingsurrogateescapebugs in edge cases. - For Jinja2/Flask apps, test autoescape is on by default in your
Environmentconfig. - Use a static analyzer (
bandit) with ruleB701to flagjinja2.Environment(autoescape=False).
from markupsafe import Markup, escape
def test_escape_basic():
assert escape("<a>") == "<a>"
def test_markup_format_escapes_args():
out = Markup("<b>{}</b>").format("<x>")
assert str(out) == "<b><x></b>"
Output: both tests pass; documents the safe-formatting behavior.
Ecosystem integrations
Jinja2— usesMarkupandescapefor autoescape and|safe. The primary consumer.Flask— re-exportsMarkup,escapefromflaskfor back-compat.Django— has its ownmark_safe/SafeString, but interoperates withmarkupsafevia 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
| Python | markupsafe line | Notes |
|---|---|---|
| 3.7 | 2.1 and earlier | Dropped in 3.x. |
| 3.8 | 2.1.x / 3.x | Floor for current. |
| 3.9 | 2.1.x / 3.x | Supported. |
| 3.10 | 2.1.x / 3.x | Supported. |
| 3.11 | 2.1.x / 3.x | Supported. |
| 3.12 | 2.1.x / 3.x | Supported. |
| 3.13 | 3.x | GIL builds; free-threaded experimental. |
Troubleshooting common errors
| Error / Symptom | Likely cause | Fix |
|---|---|---|
Output contains literal < instead of < in templates | Wrapping escape() output in another escape() or |e filter | escape is idempotent — usually fine. If double-encoding appears, look for Markup(escape(...)) patterns. |
XSS in browser despite |safe not used | Building HTML with f"<div>{x}</div>" without escaping | Use Jinja2 templates with autoescape, or Markup("...").format(x). |
TypeError: unsupported operand type(s) for +: 'Markup' and 'NoneType' | Concatenating Markup with None | Guard with or "" or Markup("") before concat. |
| Slow render | Python fallback used (no C extension) | Reinstall on a supported platform to get the speedups wheel. |
AttributeError: 'str' object has no attribute '__html__' from Django interop | Mixing markupsafe Markup with Django's SafeText | Both implement __html__(); check which framework's mark_safe you're using. |
Curly braces in literal HTML break Markup.format() | format interprets {...} as substitution | Use 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.escapeis fine. - HTML sanitization (accepting user-supplied HTML). Use
bleachorlxml.html.clean—markupsafeonly escapes, doesn't sanitize tags. - Non-HTML contexts (JS, CSS, URL). Use
json.dumps, CSS-specific escaping, orurllib.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 markupsafesomewhere.
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.
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"><bad></div>. The el helper escapes text children and attribute values; it preserves Markup children verbatim.
Step 2 — compose pieces.
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.
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.
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.
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"><new></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
| Context | Right escape | Wrong choices |
|---|---|---|
| HTML body text | escape() / markupsafe.Markup | html.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 parameter | urllib.parse.quote (then escape if dropping in HTML) | escape() alone (won't handle %, &) |
| URL path segment | urllib.parse.quote(s, safe="") | escape() |
Inside <script> JS literal | json.dumps(...) + replace("</", "<\\/") | escape() — does nothing for JS |
Inside <style> CSS | CSS-specific escaping (rare; usually emit external stylesheets) | escape() — does nothing for CSS |
| Inside an HTML comment | Strip --> from input; escape() doesn't help here | Trusting escape() |
Inside a srcdoc attribute | Both 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