MarkupSafe is the small Python library that makes it impossible — or as close to impossible as a library can — to forget HTML escaping in templated output. It is the foundation of Jinja2's autoescape, of Flask's response rendering, and of any framework in the Pallets ecosystem that has to weave user input into HTML without producing an XSS hole.
Everything in MarkupSafe orbits two names: the escape() function, which
turns a hostile string into a safe one, and the Markup class, a str
subclass that remembers it is safe and refuses to be escaped a second time.
Together they implement a single, narrow contract: a value is either
trusted-for-HTML or it is not, and the type system tells you which.
This guide is for application developers and library authors who reach for
MarkupSafe directly — for example, when building a custom template helper,
sending an HTML email, or implementing a __html__ method on a domain model.
If you only ever interact with MarkupSafe through Jinja2 (which most Flask
users do), the Quick Start tells you why your strings already do the right
thing and you can stop reading.
1. Quick Start
pip install markupsafe
from markupsafe import Markup, escape
user_input = '<script>alert("xss")</script>'
safe = escape(user_input)
print(safe)
# <script>alert("xss")</script>
greeting = Markup("<h1>Hello, %s!</h1>") % user_input
print(greeting)
# <h1>Hello, <script>alert("xss")</script>!</h1>
That is the entire core idea. escape() turned five characters
(& < > ' ") into HTML entities. Markup("<h1>...") declared a literal
template safe; the % operator escaped the substituted value. The fixed
HTML stayed un-escaped; the user input became inert text.
Tip
If you are writing a Flask or Jinja2 application, you almost certainly do not need to call
escape()yourself. Jinja2's autoescape calls it for you on every{{ variable }}substitution. MarkupSafe ships as a Jinja2 dependency, sopip install jinja2already installed it.
What MarkupSafe gives you
escape(s)— the canonical "make this string safe for HTML" function.Markup— astrsubclass whose every operator and method preserves the safety guarantee.- The
__html__protocol — any object can declare itself HTML-safe by implementing a single method. escape_silent(s)— likeescape()but treatsNoneas""instead of the literal string"None".soft_str(s)— coerce tostrwithout un-escaping aMarkup.- A C accelerator — when the build environment allows it, escaping is
done by
_speedups.c; otherwise the pure-Python_nativemodule takes over transparently.
The five characters MarkupSafe escapes
| Input | Output | Why |
|---|---|---|
& |
& |
First, so later replacements don't double-encode. |
< |
< |
Opens tags. |
> |
> |
Closes tags. |
' |
' |
Breaks single-quoted attributes. |
" |
" |
Breaks double-quoted attributes. |
Anything else — Unicode letters, emoji, control characters — passes through unchanged. MarkupSafe is exclusively an HTML escaper. It does not touch URLs, JSON, JavaScript string literals, CSS, or shell commands.
Important
MarkupSafe is HTML/XML-only. Use
urllib.parse.quote()for URL components,json.dumps()for JavaScript values,shlex.quote()for shell arguments, andmarkupsafe.escape()for HTML/XML body or attribute content. Mixing contexts is the most common XSS bypass; do not reach forescape()to sanitize a URL.
What to try next
- Section 2 (Installation) covers the pip-only path, the dev install, and how to know whether the C accelerator is active.
- Section 4 (Core Usage) walks through
escape,Markup, the__html__protocol, formatting, and unescape/striptags. - Section 5 (Common Tasks) gives short recipes for the cases that come up
most: implementing
__html__, building a snippet, JSON-serializing a Markup, and integrating with Jinja2.
2. Installation
The lowest-friction path is one line:
pip install markupsafe
That installs the latest release from PyPI, with prebuilt wheels for every mainstream platform (Linux, macOS, Windows; CPython 3.10 and newer; both x86_64 and arm64). The wheels include the compiled C accelerator, so the "speedups" path is active out of the box on supported interpreters.
You probably already have it
python -c "import markupsafe; print(markupsafe.__name__)"
# markupsafe
If you've installed Flask, Jinja2, or any other Pallets package — or any framework that depends on Jinja2 — MarkupSafe is already on your system as a transitive dependency. You can skip directly to Section 4.
Verify the install
from markupsafe import Markup, escape
print(escape("<b>")) # Markup('<b>')
print(Markup("<b>") + "</b>") # Markup('<b></b>')
print(type(escape("x"))) # <class 'markupsafe.Markup'>
If those three lines run without error, the package is functional.
Note
Wheels are published for CPython only. PyPy, Jython, and GraalVM users get the pure-Python fallback automatically —
setup.pydetects the implementation and skips the C extension build entirely. Functionality is identical; only escape throughput differs.
Platform-specific install
| Platform | Command | Notes |
|---|---|---|
| pip (any) | pip install markupsafe |
Recommended. Wheels for Linux/macOS/Windows. |
| uv | uv add markupsafe |
Project-managed installs. The repo itself uses uv. |
| pipx (for tools that embed it) | pipx inject yourtool markupsafe |
Inject into a pipx-installed app. |
| conda | conda install -c conda-forge markupsafe |
Conda-forge channel. |
| From source | pip install . (in repo root) |
Builds the C extension if a compiler is present. |
Tip
On a fresh container or CI image you almost never need to do anything special. The wheel index resolves before pip even tries to compile, and the install completes in under a second.
Source / development install
To hack on MarkupSafe itself or to run its test suite, clone the repo and
use uv (the upstream-preferred toolchain pinned in .readthedocs.yaml):
git clone https://github.com/pallets/markupsafe.git
cd markupsafe
uv sync
uv run pytest
uv sync reads pyproject.toml and uv.lock to install the package
editable, plus the test and docs groups. The first uv run pytest after a
fresh clone will compile the C extension into the source tree.
Important
If you're installing from source on a system without a C compiler, the install does not fail.
setup.pycatchesCCompilerError,ExecError, andPlatformError, prints a banner that begins withWARNING: The C extension could not be compiled, and re-runs without the extension. The library still works — escaping is just slower.
Forcing the C extension to build (CI builds)
Continuous integration jobs that build wheels (cibuildwheel) need the
build to fail loudly if the C extension can't compile, rather than silently
falling back. setup.py honours one environment variable:
| Variable | Default | Effect |
|---|---|---|
CIBUILDWHEEL |
0 |
Set to 1 to force C-extension build with no fallback. |
CIBUILDWHEEL=1 pip install --no-binary markupsafe markupsafe
This is exclusively for wheel-building pipelines; ordinary users should leave it unset.
Uninstall
pip uninstall markupsafe
Be aware that almost any web framework you also have installed depends on MarkupSafe and will refuse to import without it.
3. Configuration
MarkupSafe has no configuration. It is a single-purpose library with no files to edit, no environment variables to read at runtime, no plugin registry, and no global state.
The complete list of "settings" you can tune:
| Knob | Where | What it does |
|---|---|---|
| Use the C accelerator | Build-time | Controlled by whether _speedups.c compiled. |
| Force C build | CIBUILDWHEEL=1 env var |
Build-only switch — see Section 2. |
Custom __html__ |
Per class | Opt your own classes into safe-rendering — see Section 4. |
Note
Behaviour is identical between the C-accelerated path (
markupsafe._speedups._escape_inner) and the pure-Python path (markupsafe._native._escape_inner). The only observable difference is throughput on long strings. There is no flag to choose between them at runtime; whichever was importable at process start wins.
Selecting the implementation manually (advanced)
If you need to benchmark or to force the pure-Python path for debugging, shadow the symbol after import:
import markupsafe
from markupsafe._native import _escape_inner
markupsafe._escape_inner = _escape_inner
This is the technique bench.py uses to compare the two implementations
head-to-head. Outside of benchmarking and bug-hunting, do not do this in
production code; let the import-time selector decide.
4. Core Usage
Four concepts cover everything MarkupSafe does:
- The
escape()function (andescape_silent()). - The
Markupclass — astrthat knows it's safe. - The
__html__protocol — how arbitrary classes opt in. Markup.format,Markup.unescape,Markup.striptags, and the operators.
4.1 escape() — the canonical safe-conversion
from markupsafe import escape
escape("<b>bold</b>") # Markup('<b>bold</b>')
escape(42) # Markup('42')
escape(None) # Markup('None') <-- note: literal string
The signature is escape(s, /) -> Markup. The argument can be anything;
escape() does three things in this order:
- If
type(s) is str, escape directly (the fast path — most common case). - Otherwise, if
shas an__html__method, call it and trust the return. - Otherwise, call
str(s)and escape that.
The return is always a Markup instance — never a plain str — so calling
escape() twice on the same value is idempotent in the sense that the
double-escape only happens once you cast back to str. (See soft_str in
4.5 for why this matters.)
Note
The fast path uses
type(s) is str, notisinstance(s, str). This is deliberate: aMarkupis also astr, but it should hit the__html__branch and return itself, not be re-escaped.
4.2 escape_silent() — None-tolerant escape
from markupsafe import escape, escape_silent
escape(None) # Markup('None') <-- usually NOT what you want
escape_silent(None) # Markup('')
escape_silent("ok") # Markup('ok')
Use escape_silent when you're rendering a template that may receive
optional values and you want missing data to disappear rather than print
the word "None". Behaviour for non-None values is identical to escape().
4.3 The Markup class
Markup is a str subclass. The constructor wraps without escaping:
from markupsafe import Markup
Markup("<em>yes</em>") # Markup('<em>yes</em>') -- not escaped
Markup.escape("<em>yes</em>") # Markup('<em>yes</em>') -- escaped
The split is the heart of the API:
Markup(value)says "I am asserting this is already safe."Markup.escape(value)(or the module-levelescape(value)) says "I do not trust this; please make it safe."
Caution
Calling
Markup()directly on user input completely defeats MarkupSafe's protection.Markup(request.form["name"])produces a string that templates will render verbatim — including any<script>tags inside it. Always useescape()for untrusted input. The plainMarkup()constructor is for literal HTML you wrote (for example, layout templates, icon snippets, or pre-validated content from a trusted CMS).
Markup arithmetic preserves safety
Once you have a Markup, every operator and string method either preserves
the safety guarantee or escapes any unsafe operand:
Markup("<b>Hi</b>") + "<u>" # Markup('<b>Hi</b><u>')
"<u>" + Markup("<b>Hi</b>") # Markup('<u><b>Hi</b>')
Markup("<em>%s</em>") % "<x>" # Markup('<em><x></em>')
Markup(", ").join(["<a>", "<b>"]) # Markup(', ').join(...) -> escapes both
Markup("<b>x</b>").upper() # Markup('<B>X</B>')
Markup("<b>x</b>")[0] # Markup('<') (slicing returns Markup)
Methods that return strings (e.g. upper, lower, replace, strip,
split, partition, expandtabs, zfill, casefold, removeprefix,
removesuffix, capitalize, title, swapcase, ljust, rjust,
center) return Markup. Methods that take a string argument (replace,
ljust, rjust, center, join) escape that argument before applying
it. split, rsplit, splitlines, partition, rpartition all return
lists or tuples of Markup instances.
unescape(), striptags(), and translate() return plain str,
because their job is to leave the HTML world.
Concatenation rules in detail
Markup + str→ escapes the right side, returnsMarkup.str + Markup→ escapes the left side, returnsMarkup.Markup + non-string→ returnsNotImplemented(lets Python try the other operand's__radd__), then aTypeErrorif nothing handles it.Markup * intandint * Markup→ repeat, returnMarkup.Markup % value→ printf-style format with each substitution escaped (see 4.4).
4.4 Formatting: %, format, and format_map
Three formatting paths exist; all three escape substitutions.
from markupsafe import Markup
# 1. Printf style
Markup("<a href='%s'>%s</a>") % ("/x?y=1&z=2", "<click>")
# Markup("<a href='/x?y=1&z=2'><click></a>")
# 2. str.format positional
Markup("<a>{}</a>").format("<click>")
# Markup('<a><click></a>')
# 3. str.format_map mapping
Markup("<a>{name}</a>").format_map({"name": "<click>"})
# Markup('<a><click></a>')
Markup.format and Markup.format_map route through an internal
EscapeFormatter (a string.Formatter subclass) that calls escape() on
every field value. Format specifiers ({:>10}, {:.2f}, etc.) work as
they do on plain strings. If a substituted object defines __html_format__,
that method receives the format spec and is responsible for returning a
Markup-safe string.
Warning
Passing a non-empty format spec to a value that defines
__html__but not__html_format__raisesValueError:Format specifier {format_spec} given, but {type(value)} does not define __html_format__.Either drop the format spec or implement__html_format__(see 5.3).
4.5 The __html__ protocol
Any object can declare itself HTML-safe by implementing one method:
class Link:
def __init__(self, href, text):
self.href = href
self.text = text
def __html__(self):
# We control the markup; the text needs escaping.
from markupsafe import escape
return f'<a href="{escape(self.href)}">{escape(self.text)}</a>'
What this gets you:
escape(Link("/x", "<y>"))returns the result ofLink.__html__()un-modified, wrapped inMarkup.Markup(Link("/x", "<y>"))does the same — wraps without re-escaping.Markup("<div>%s</div>") % link_instancesubstitutes the safe HTML.
Optionally implement __html_format__(self, format_spec) to support format
specs in Markup.format calls.
class Money:
def __init__(self, cents):
self.cents = cents
def __html__(self):
return f"<span class='money'>${self.cents / 100:.2f}</span>"
def __html_format__(self, spec):
# spec is something like '>15' or '0.0' from {:>15} etc.
from markupsafe import Markup
return Markup(format(self.__html__(), spec)) if spec else Markup(self.__html__())
Tip
Implementing
__html__is the cleanest way to integrate domain objects with Jinja2. The template author writes{{ link }}; Jinja2 callsescape();escape()sees__html__; the safe string lands in the output unchanged.
4.6 Markup.unescape() and Markup.striptags()
These two methods reverse direction — they return plain str.
from markupsafe import Markup
Markup("Main » <em>About</em>").unescape()
# 'Main » <em>About</em>'
Markup("Main »\t<em>About</em>").striptags()
# 'Main » About'
unescape() calls html.unescape from the standard library — entities
become their characters, but tags are left alone.
striptags() removes HTML comments first (so a comment containing a tag
doesn't truncate the strip), then everything between < and >,
collapses runs of whitespace into single spaces, and finally calls
unescape() on the result. The output is suitable for plain-text contexts
(email previews, search indices, log lines).
4.7 soft_str()
from markupsafe import escape, soft_str
value = escape("<User 1>") # Markup('<User 1>')
escape(str(value)) # Markup('&lt;User 1&gt;') <-- DOUBLE ESCAPED
escape(soft_str(value)) # Markup('<User 1>') <-- correct
soft_str is the answer to "I want a str, but please don't lose the
fact that this Markup was already safe." It returns the input unchanged
if it's any str subclass (including Markup), and str(s) otherwise.
Use it when interfacing with code that demands a literal str type and
might naively re-escape what it receives.
4.8 Summary of the public surface
| Name | Type | Purpose |
|---|---|---|
escape |
function | Make a value safe for HTML, return Markup. |
escape_silent |
function | Like escape, but None becomes ''. |
soft_str |
function | Cast to str without losing Markup identity. |
Markup |
class (subclass of str) |
Mark a literal string safe; arithmetic preserves safety. |
Markup.escape |
classmethod | Same as escape() but returns the calling subclass. |
Markup.unescape |
method | Markup → str, decode HTML entities. |
Markup.striptags |
method | Markup → str, remove tags + comments + collapse spaces. |
__html__ |
protocol method | Any class can implement to declare itself safe. |
__html_format__ |
protocol method | Any class can implement to support Markup.format specs. |
Internal names (_native._escape_inner, _speedups._escape_inner,
_HasHTML, _TPEscape, EscapeFormatter, _MarkupEscapeHelper) are
implementation detail and not covered here. They are documented in the
Admin Guide.
5. Common Tasks
Eight short recipes that cover the cases that come up most.
5.1 Escape a single user input
from markupsafe import escape
name = request.form["name"] # untrusted
safe_name = escape(name) # Markup
return f"<h1>Hello, {safe_name}!</h1>" # but the surrounding string is plain str!
Warning
The example above is almost right. Because
safe_namelands inside an f-string with a plain-strliteral, the result is a plainstr. That's fine if you return it directly to a browser asContent-Type: text/html, but it loses the safety marker — any subsequentescape()call will double-encode it. The next recipe shows the cleaner pattern.
5.2 Build an HTML fragment that mixes safe + unsafe content
from markupsafe import Markup, escape
heading = Markup("<h1>Hello, %s!</h1>") % request.form["name"]
# Or:
heading = Markup("<h1>Hello, {}!</h1>").format(request.form["name"])
# Or:
heading = Markup("<h1>Hello, ") + request.form["name"] + Markup("!</h1>")
All three keep heading as Markup. Pick whichever reads best for the
template you're building. % formatting is the canonical choice in
Jinja2's filters.
5.3 Implement __html__ on a custom class
from dataclasses import dataclass
from markupsafe import Markup, escape
@dataclass
class User:
id: int
display_name: str
def __html__(self):
return Markup('<a href="/users/{0}">{1}</a>').format(self.id, self.display_name)
# Usage:
user = User(id=42, display_name="<Bobby Tables>")
escape(user)
# Markup('<a href="/users/42"><Bobby Tables></a>')
# In a Jinja template:
# {{ user }} -- renders the safe link, with display_name properly escaped
Tip
Always build the inner HTML with
Markup.format(or%) so the attribute values get escaped automatically. Hand-concatenating strings with f-strings inside__html__is a common XSS source — you have to remember to callescape()on every interpolation.
5.4 Convert a Markup back to plain text
from markupsafe import Markup
content = Markup("Hello & <em>welcome</em> to » the site!")
text = content.unescape()
# 'Hello & <em>welcome</em> to » the site!'
stripped = content.striptags()
# 'Hello & welcome to » the site!'
Use unescape() when you want HTML entities decoded but tags preserved
(rare). Use striptags() when you want a plain-text rendering — the
typical case is generating an email preview, an Atom summary, or a
search-index field from HTML body content.
5.5 Strip tags from rich input
from markupsafe import Markup
raw = Markup("""
<p>Hello <strong>World</strong>!</p>
<!-- inline tracker pixel:
<img src="https://evil.example/?u=1"> -->
<p>Visit <a href="/x">here</a>.</p>
""")
print(raw.striptags())
# Hello World ! Visit here.
striptags() correctly handles HTML comments that contain tags — the
comment is removed in one pass, then the surviving tags are stripped, and
the whitespace is collapsed.
Note
striptags()is for normalizing display, not for security. It does not protect against malicious HTML being re-rendered later. If you need to sanitize untrusted HTML for re-display, use a dedicated sanitizer such asbleach—striptagsis a one-way reduction to plain text.
5.6 Integrate with Jinja2 (transparent)
In a Jinja2 environment with autoescape enabled (which is the default for
flask.render_template and any Environment(autoescape=True)):
<!-- template.html -->
<h1>Hello, {{ name }}!</h1>
<p>{{ user_html_blob | safe }}</p>
<p>{{ user.display_name }}</p>
# Python side — nothing to do!
return render_template("template.html",
name="<World>", # auto-escaped
user_html_blob=Markup(trusted), # already Markup, NOT re-escaped
user=User(id=1, display_name="<>"), # __html__ method called
)
What NOT to do:
# WRONG — defeats autoescape entirely.
return render_template("template.html",
name=Markup(request.form["name"]), # XSS HOLE
)
{# WRONG — `safe` says "trust this verbatim" without verification. #}
<h1>Hello, {{ name | safe }}!</h1>
Caution
The Jinja2
| safefilter and the bareMarkup()constructor do the same thing: they declare a value safe without escaping. Both are correct for trusted, programmer-authored HTML; both are an XSS bug when applied to user input. Treat| safeandMarkup(x)as security-sensitive — grep for them in code review.
5.7 Serialize a Markup to JSON safely
import json
from markupsafe import Markup
data = {"snippet": Markup("<b>bold</b>")}
json.dumps(data)
# '{"snippet": "<b>bold</b>"}'
Because Markup is a str subclass, json.dumps serializes it as a
plain JSON string. The HTML is preserved verbatim — but note that the
JSON output is itself plain text, and reading it back with json.loads
returns a plain str, not a Markup. The "this is safe HTML" tag does
not survive a round-trip through JSON.
Important
If you embed JSON inside an HTML
<script>tag, the bytes still need JavaScript-context escaping (e.g.</script>inside the JSON would close the script element). Standard JSON encoding is not sufficient for HTML embedding. Use a dedicated escape pass — Flask'stojsonfilter, or hand-rolledjson.dumps(..., ensure_ascii=True)plus.replace("</", "<\\/")— for the HTML-script case.
5.8 Round-trip: escape → use → unescape
from markupsafe import escape
# Step 1: receive untrusted input
title = '<Important & Urgent>'
# Step 2: escape for storage or display
safe = escape(title)
# Markup('<Important & Urgent>')
# Step 3: render safely in HTML
html = f"<h2>{safe}</h2>" # plain str, safe to send
# Step 4: later, recover the original characters
recovered = safe.unescape()
# '<Important & Urgent>'
assert recovered == title
This pattern is useful when you're storing pre-escaped text and need the
original back for non-HTML contexts (logs, plaintext exports, fuzzy
search). Note that unescape doesn't strip tags — if the original
contained tags, they'll come back too.
6. Troubleshooting
The errors below are the ones MarkupSafe actually raises (the two
ValueError messages are quoted verbatim from the source) and the
behaviours users most often misdiagnose as bugs.
Note
MarkupSafe has a tiny error surface. If you encounter an exception that's not in this list, it is almost always coming from your framework (Jinja2, Flask) or from your own
__html__implementation, not from MarkupSafe itself. Trace the stack to the highestmarkupsafe/*.pyframe; if there isn't one, the bug is upstream of the library.
Error: TypeError: can only concatenate str (not "int") to Markup
Cause: You attempted Markup("...") + 42. Markup.__add__ returns
NotImplemented for non-string operands, and so does int.__radd__, so
Python raises TypeError.
Solution: Convert explicitly.
# Wrong:
Markup("count: ") + 42
# Right:
Markup("count: ") + str(42)
Markup("count: %d") % 42
Markup("count: {}").format(42)
Prevention: Treat Markup as a typed str. If a value isn't a string,
either coerce it (str(value)) or pass it through %/format so
MarkupSafe handles it.
Error: ValueError: Unsupported format specification for Markup.
Cause: Using a format spec on a literal Markup value, e.g.
f"{my_markup:>10}" or Markup("{:>10}").format(other_markup).
Markup.__html_format__ rejects any non-empty format spec because there
is no general way to pad a safe HTML string while keeping it safe.
Solution: Cast to str first if you need plain text alignment, then
re-wrap if needed.
m = Markup("<b>x</b>")
padded = Markup(f"{str(m):>10}")
Prevention: Apply format specs at the str layer, not at the
Markup layer.
Error: ValueError: Format specifier '...' given, but <class 'X'> does not define __html_format__
Cause: A class with __html__ but no __html_format__ was used in
Markup.format with a format spec.
Solution: Either remove the spec, or implement __html_format__:
class Money:
def __html__(self):
return f"<span>${self.cents/100:.2f}</span>"
def __html_format__(self, spec):
from markupsafe import Markup
return Markup(format(self.__html__(), spec)) if spec else Markup(self.__html__())
Prevention: When you implement __html__, decide up front whether
your class will be used with format specs. If yes, implement
__html_format__ at the same time.
Markup('None') appearing in output where a value is missing
Cause: You called escape(None). escape() calls str(None) which
produces the literal string "None", then escapes it (no-op for None's
characters).
Solution: Use escape_silent:
from markupsafe import escape_silent
escape_silent(None) # Markup('')
escape_silent(value) # behaves like escape() for non-None
Prevention: Use escape_silent for any value that might be None
(optional database fields, query params, defaulted kwargs).
Output is double-escaped (&lt; instead of <)
Cause: A Markup value was cast back to str and then re-escaped.
This happens when you write escape(str(some_markup)) or when a layer of
your stack explicitly calls str() on a Markup before re-escaping.
Warning
Double-escape bugs are easy to miss in code review because the rendered page still looks valid —
&lt;displays as<in the browser rather than as the original<. The user-visible symptom is "my entities are showing up as literal text". Always test by viewing-source in the browser, not by visual inspection.
Solution: Use soft_str whenever you must coerce to str:
from markupsafe import escape, soft_str
escape(soft_str(some_markup)) # idempotent — no double escape
Prevention: Don't pre-escape data that will pass through Jinja2 or
another autoescape layer. If you must, ensure the entire pipeline keeps
Markup identity, and use soft_str at any seam where a plain str
is required.
"The C extension could not be compiled, speedups are not enabled."
Cause: Installing from source on a system without a working C
compiler. setup.py caught the build failure and re-ran without the
extension. The library still works.
Solution: No action needed if you're a user. To get the speedups,
install a compiler (Linux: gcc; macOS: Xcode CLT; Windows: Build Tools
for Visual Studio) and reinstall, or — easier — install from a wheel:
pip install --upgrade --force-reinstall --no-cache-dir markupsafe.
Prevention: Stick to wheels (pip install markupsafe) instead of
source builds in environments where you don't control the toolchain.
ImportError from markupsafe._speedups
Cause: You're either on PyPy/Jython/GraalVM (where the speedups are
intentionally not built — setup.py checks
platform.python_implementation()), or you have a binary installed for a
different Python ABI than the one running.
Solution: Don't import _speedups directly — markupsafe/__init__.py
falls back to _native automatically. If you have a stale install,
pip install --upgrade --force-reinstall markupsafe rebuilds against the
current interpreter.
Prevention: Don't pin to a CPython-specific wheel in cross-runtime environments. Pin the source distribution in PyPy-targeted lock files.
Custom HTML tags appear as plain text in output
Cause: You wrote {{ "<custom>" }} in a Jinja template (or
escape("<custom>") in Python) and got <custom> in the output.
This is correct behaviour — the string was escaped because it was treated
as untrusted.
Solution: If the snippet is genuinely yours (not user input), wrap it
in Markup:
{{ Markup("<custom>") }} # in a context with autoescape
{{ "<custom>"|safe }} # equivalent in template
Prevention: Author HTML snippets in Python (with Markup) or in
templates (as literal markup), not as data passed in. Keep the safety
boundary between "code" and "data" sharp.
7. FAQ
Q: Is MarkupSafe safe to use with non-HTML contexts (URLs, JSON, JS)?
No. escape() only handles the five characters that matter in HTML/XML
text and attribute contexts. URLs need urllib.parse.quote; JavaScript
string literals need JSON encoding plus careful </script> handling;
shell arguments need shlex.quote. Mixing contexts is the most common
class of XSS bypass.
Q: Does escape() handle Unicode?
Yes, transparently. The function only touches & < > ' ". Every other
character — including emoji, RTL text, and the entire BMP and astral
planes — passes through unchanged. The C accelerator uses three
specialized paths for 1-byte, 2-byte, and 4-byte string kinds, all of
which are bytewise identical in semantics.
Q: What's the performance difference between _native and _speedups?
Run python bench.py from the repo root — it uses pyperf to time five
benchmarks (short escape, long escape, short plain, long plain, long
suffix) against both implementations. In typical CPython 3.13 environments
the C path is several times faster on long strings; on short strings the
Python overhead dominates and the difference is small. Either way,
escape() is rarely the bottleneck in a real application.
Q: Why does Markup("...") not escape, but Markup.escape("...") does?
Because they answer different questions. Markup(s) says "trust this
exactly as written" — useful for layout snippets, icons, pre-validated
content. Markup.escape(s) says "make this safe first" — equivalent to
the module-level escape() and what you should use for any data that
came from outside your code.
Q: Can I subclass Markup?
Yes. Markup is a regular str subclass with __slots__ = () (no
instance dict). All operators preserve the calling subclass through
self.__class__, and the classmethod Markup.escape honours the calling
class. Define your subclass, optionally override __html_format__, and
the rest comes for free.
Q: Does Markup work with f-strings?
It works in the sense that interpolation produces plain str, not
Markup:
m = Markup("<b>safe</b>")
result = f"prefix {m} suffix" # type: str, NOT Markup
If you need to preserve the safety marker, use %, Markup.format, or
explicit concatenation instead of f-strings.
Q: What happens if I + two Markup instances?
The result is Markup. Both operands are already safe; concatenation
preserves that.
Q: How do I tell if a value is Markup or plain str?
from markupsafe import Markup
isinstance(value, Markup)
In practice, you rarely need to ask. Use soft_str at boundary points
and let the API do the right thing.
Q: Does MarkupSafe escape backticks or forward slashes?
No. Only & < > ' ". Backticks (`) and slashes (/) are not
HTML-special in standards-compliant contexts; legacy IE bugs that treated
backticks as quote characters are no longer relevant. If you need
extra-paranoid escaping for an old browser, use a dedicated sanitizer.
Q: Is striptags() a safe HTML sanitizer?
No. It is a one-way reduction to plain text. Do not feed its output back into HTML expecting it to be safe in some intermediate way — once you've stripped tags, the resulting string is just plain text and should be re-escaped if it goes back into HTML.
Q: Does MarkupSafe have any runtime configuration?
None. The only build-time switch is the CIBUILDWHEEL environment
variable consumed by setup.py. There are no logger names, plugin
hooks, monkey-patch points, or dispatch registries. The library is
deliberately small.
Q: Where can I read about how Jinja2 uses MarkupSafe?
See the Jinja2 documentation on autoescape. The short version: every
{{ ... }} expression in an autoescape-enabled environment runs
escape() on the value, and the | safe filter wraps a value in
Markup().
Q: How do I test my own __html__ implementation?
Call escape() on an instance and check the return:
from markupsafe import escape, Markup
result = escape(MyObject(data="<dangerous>"))
assert isinstance(result, Markup)
assert "<dangerous>" not in result # if you escaped the data internally
assert "<dangerous>" in result
Q: What versions of Python are supported?
Modern CPython (3.10 and newer for current releases) plus PyPy / Jython / GraalVM via the pure-Python fallback. Check the wheel index on PyPI for the exact set published with the release you're installing.
Q: Where do I report a bug?
https://github.com/pallets/markupsafe/issues. Security-sensitive
findings (e.g. an escape bypass) should be reported privately following
the Pallets project's security policy rather than as a public issue.