Jesse A. Green / notebook

MarkupSafe User Guide

Generated April 2026 from pallets/markupsafe at b2e4d9c

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

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)
# &lt;script&gt;alert(&#34;xss&#34;)&lt;/script&gt;

greeting = Markup("<h1>Hello, %s!</h1>") % user_input
print(greeting)
# <h1>Hello, &lt;script&gt;alert(&#34;xss&#34;)&lt;/script&gt;!</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, so pip install jinja2 already installed it.

What MarkupSafe gives you

  • escape(s) — the canonical "make this string safe for HTML" function.
  • Markup — a str subclass 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) — like escape() but treats None as "" instead of the literal string "None".
  • soft_str(s) — coerce to str without un-escaping a Markup.
  • A C accelerator — when the build environment allows it, escaping is done by _speedups.c; otherwise the pure-Python _native module takes over transparently.

The five characters MarkupSafe escapes

Input Output Why
& &amp; First, so later replacements don't double-encode.
< &lt; Opens tags.
> &gt; Closes tags.
' &#39; Breaks single-quoted attributes.
" &#34; 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, and markupsafe.escape() for HTML/XML body or attribute content. Mixing contexts is the most common XSS bypass; do not reach for escape() 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('&lt;b&gt;')
print(Markup("<b>") + "</b>")   # Markup('<b>&lt;/b&gt;')
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.py detects 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.py catches CCompilerError, ExecError, and PlatformError, prints a banner that begins with WARNING: 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:

  1. The escape() function (and escape_silent()).
  2. The Markup class — a str that knows it's safe.
  3. The __html__ protocol — how arbitrary classes opt in.
  4. 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('&lt;b&gt;bold&lt;/b&gt;')
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:

  1. If type(s) is str, escape directly (the fast path — most common case).
  2. Otherwise, if s has an __html__ method, call it and trust the return.
  3. 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, not isinstance(s, str). This is deliberate: a Markup is also a str, 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('&lt;em&gt;yes&lt;/em&gt;') -- 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-level escape(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 use escape() for untrusted input. The plain Markup() 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>&lt;u&gt;')
"<u>" + Markup("<b>Hi</b>")            # Markup('&lt;u&gt;<b>Hi</b>')
Markup("<em>%s</em>") % "<x>"          # Markup('<em>&lt;x&gt;</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, returns Markup.
  • str + Markup → escapes the left side, returns Markup.
  • Markup + non-string → returns NotImplemented (lets Python try the other operand's __radd__), then a TypeError if nothing handles it.
  • Markup * int and int * Markup → repeat, return Markup.
  • 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&amp;z=2'>&lt;click&gt;</a>")

# 2. str.format positional
Markup("<a>{}</a>").format("<click>")
# Markup('<a>&lt;click&gt;</a>')

# 3. str.format_map mapping
Markup("<a>{name}</a>").format_map({"name": "<click>"})
# Markup('<a>&lt;click&gt;</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__ raises ValueError: 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 of Link.__html__() un-modified, wrapped in Markup.
  • Markup(Link("/x", "<y>")) does the same — wraps without re-escaping.
  • Markup("<div>%s</div>") % link_instance substitutes 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 calls escape(); 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 &raquo; <em>About</em>").unescape()
# 'Main » <em>About</em>'

Markup("Main &raquo;\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('&lt;User 1&gt;')
escape(str(value))                  # Markup('&amp;lt;User 1&amp;gt;')  <-- DOUBLE ESCAPED
escape(soft_str(value))             # Markup('&lt;User 1&gt;')          <-- 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_name lands inside an f-string with a plain-str literal, the result is a plain str. That's fine if you return it directly to a browser as Content-Type: text/html, but it loses the safety marker — any subsequent escape() 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">&lt;Bobby Tables&gt;</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 call escape() on every interpolation.

5.4 Convert a Markup back to plain text

from markupsafe import Markup

content = Markup("Hello &amp; <em>welcome</em> to &raquo; 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 as bleach — striptags is 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 | safe filter and the bare Markup() 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 | safe and Markup(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's tojson filter, or hand-rolled json.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('&lt;Important &amp; Urgent&gt;')

# 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 highest markupsafe/*.py frame; 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 (&amp;lt; instead of &lt;)

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 — &amp;lt; displays as &lt; 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 &lt;custom&gt; 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 "&lt;dangerous&gt;" 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.