MarkupSafe is a small, single-purpose Python library that provides the type-tagged HTML/XML escaping primitives consumed by Jinja2 (and by extension Flask, Quart, Sanic-Jinja2, and most templating in the Pallets ecosystem). The entire library is one Python module of roughly 380 lines plus a 200-line C accelerator. Despite the small size, it is a load-bearing piece of the Python web stack: tens of thousands of applications rely on its correctness for first-line XSS defence.
This guide covers the architecture, build system, security model, and internals that operators, security reviewers, and downstream library authors need. For day-to-day usage by application developers, see the companion User Guide.
1. Architecture Overview
MarkupSafe is a library, not a service. There is no daemon, no port to listen on, no configuration file. The architecture is the dataflow of a single string from untrusted input to safe HTML output.
Data flow
UNTRUSTED INPUT
|
v
+----------------------+
| markupsafe.escape | <-- entry point (src/markupsafe/__init__.py:24)
+----------------------+
|
| type(s) is str ?
|--- yes --> _escape_inner(s) # fast path
|--- no, has __html__ ? -> s.__html__() # protocol path
|--- otherwise --> _escape_inner(str(s)) # fallback path
|
v
+----------------------+
| Markup(_escape_...) | <-- tagged-string wrapper
+----------------------+
|
v
+----------------------+ +-------------------+
| _speedups._escape... | OR | _native._escape.. | (build-time choice)
| (C accelerator) | | (pure Python) |
+----------------------+ +-------------------+
|
v
SAFE HTML / XML --> Jinja2 / Flask response / file / email body
The entire public surface — escape, escape_silent, soft_str,
Markup — is exported from src/markupsafe/__init__.py. The two leaf
implementations of _escape_inner are interchangeable: one in Python
(_native.py, 7 lines), one in C (_speedups.c, ~200 lines). The
import in __init__.py (lines 9-12) selects the C version if it imports
cleanly, otherwise the Python version.
Component inventory
| Component | Path | Role |
|---|---|---|
| Public API + Markup class | src/markupsafe/__init__.py |
All exported names; Markup, escape, escape_silent, soft_str, EscapeFormatter, helper. |
| Pure-Python escaper | src/markupsafe/_native.py |
5-replace pure-Python implementation of _escape_inner. |
| C accelerator | src/markupsafe/_speedups.c |
Three Unicode-kind specializations of the same algorithm. |
| C extension stubs | src/markupsafe/_speedups.pyi |
Type stubs for the compiled module. |
| Type marker | src/markupsafe/py.typed |
PEP 561 marker so consumers see the .pyi stubs. |
| Build system | setup.py |
Optional C extension build with graceful fallback. |
| Project metadata | pyproject.toml (in repo root) |
Build backend, dependencies, tool config. |
| Lockfile | uv.lock |
Reproducible dev install via uv sync. |
| Benchmark | bench.py |
pyperf-driven _native vs _speedups comparison. |
| Pre-commit config | .pre-commit-config.yaml |
Ruff (lint + format), uv-lock, basic file hygiene. |
| ReadTheDocs config | .readthedocs.yaml |
Sphinx build via uv run --group docs. |
Note
The library deliberately has no plugin system, no
entry_pointsgroup, no monkey-patch hooks, no global state. The single dispatch decision — "C accelerator or Python fallback?" — is made exactly once, at import time, by thetry / except ImportErrorblock in__init__.py. Once the import has resolved, the binding does not change for the lifetime of the process.
Technology stack
| Layer | Choice | Why |
|---|---|---|
| Build backend | flit_core (declarative) |
Pure-Python part. Standard for Pallets projects. |
| C extension build | setuptools + custom build_ext |
Required for the optional _speedups extension. |
| Toolchain (dev) | uv (Astral) |
Pinned in .readthedocs.yaml; uv.lock is committed. |
| Lint & format | ruff + ruff-format |
Pre-commit hooks pinned to v0.13.2. |
| Docs | Sphinx (RST) | Build target in .readthedocs.yaml. |
| Test framework | pytest |
Test files in tests/. |
| Type checking | PEP 561 stubs (py.typed) |
Stubs travel with the package; mypy/pyright pick them up. |
Trust boundaries
The library defines two type-level trust states:
- Trusted (
Markup) — values that are safe to embed in HTML/XML verbatim. Created byMarkup(...)(asserted-safe),escape(...)(made-safe), or any operator/method on an existingMarkup. - Untrusted (any other
stror__html__-less object) — values that must pass throughescape()before reaching HTML output.
The boundary is enforced by the type system: every Markup operator
calls self.escape(operand) on incoming str values, so concatenation,
formatting, and joining cannot accidentally widen the trust set. The
single failure mode — calling Markup() on untrusted data — is
documented explicitly because it bypasses every other check. See
Section 7.
2. System Requirements
| Item | Requirement |
|---|---|
| Python | CPython 3.10+ for current releases; PyPy / Jython / GraalVM for the pure-Python fallback |
| Memory | Negligible — under 1 MB resident for the imported module |
| Disk | Under 200 KB installed (single Python module + small C extension) |
| Network | None — purely in-process |
| OS | Any platform CPython supports. Wheels published for Linux, macOS, Windows; manylinux x86_64 + arm64; macOS x86_64 + arm64; Windows x86 + x86_64 |
| C compiler (build from source only) | gcc / clang (Linux/macOS) or MSVC Build Tools (Windows). Build is optional — gracefully falls back to pure Python. |
| Python C API | Uses PyUnicode_KIND, PyUnicode_GET_LENGTH, PyUnicode_New, PyUnicode_1/2/4BYTE_DATA. Compatible with the limited API for Python 3.10+. |
Per-interpreter capability flags
The C extension declares two module slots that signal cooperation with modern CPython runtime modes:
| Slot | Effect | Source |
|---|---|---|
Py_mod_multiple_interpreters = Py_MOD_PER_INTERPRETER_GIL_SUPPORTED |
Module is safe for sub-interpreters with their own GIL (3.12+). | src/markupsafe/_speedups.c:572 |
Py_mod_gil = Py_MOD_GIL_NOT_USED |
Module does not require the GIL (3.13+ free-threaded build). | src/markupsafe/_speedups.c:575 |
Both flags compile conditionally on the minimum Python version, so the extension still builds against older CPython where the macros don't exist.
Note
Setting
Py_MOD_GIL_NOT_USEDon the free-threaded build (PEP 703) is a deliberate, audited claim. The_escape_innerC function is purely functional — it allocates a freshPyUnicodeObject, copies bytes, and returns. It does not touch shared mutable state, so it does not need the GIL even when other extension modules in the process do.
3. Configuration Reference
Build-time configuration
| Variable | Type | Default | Description | Required? |
|---|---|---|---|---|
CIBUILDWHEEL |
env var, read by setup.py:60 |
0 |
When 1 and the implementation supports speedups, force a C-extension build with no fallback. Used by wheel-building CI to fail loudly if the extension can't compile. |
No (only for CI build pipelines) |
This is the complete list of configuration MarkupSafe consumes. There
is no runtime configuration — no environment variables read at import
time other than CIBUILDWHEEL, no config files, no CLI flags, no API to
toggle behaviour.
Build behaviour matrix
setup.py uses platform.python_implementation() and the CIBUILDWHEEL
flag to choose one of three paths:
| Implementation | CIBUILDWHEEL=1 |
Otherwise |
|---|---|---|
| CPython | Build C extension; fail hard on error. | Try to build; on BuildFailed, print a warning banner and re-run setup() without the extension. |
| PyPy / Jython / GraalVM | Skip extension; print "C extensions are not supported on this Python platform" banner. | Same. |
The BuildFailed exception is raised from ve_build_ext.run and
ve_build_ext.build_extension when any of CCompilerError, ExecError,
PlatformError, or a ValueError mentioning the literal 'path'
occurs. The 'path' check is a Windows-64 workaround for Python issue
7511.
Pre-commit hooks (developer configuration)
| Hook | Pin | Purpose |
|---|---|---|
ruff |
f298305809c552671cc47e0fec0ba43e96c146a2 (frozen v0.13.2) |
Lint Python source. |
ruff-format |
same revision | Format Python source. |
uv-lock |
9e8320f8d22dfa502984f75bb72d6bba825e570c (frozen 0.8.22) |
Keep uv.lock in sync with pyproject.toml. |
check-merge-conflict |
3e8a8703264a2f4a69428a0aa4dcb512790b2c8c (frozen v6.0.0) |
Block merge-conflict markers. |
debug-statements |
same | Block stray pdb.set_trace() etc. |
fix-byte-order-marker |
same | Strip BOM from text files. |
trailing-whitespace |
same | Trim trailing whitespace. |
end-of-file-fixer |
same | Ensure files end with a newline. |
Important
The pre-commit revisions are pinned to specific commit SHAs (the "frozen" comments mark the corresponding tag at install time). This is a deliberate supply-chain hardening measure: a tag move on the upstream repo would not silently change linter behaviour on the next
pre-commit install. Bump these by re-runningpre-commit autoupdateafter auditing the diff.
Documentation build
The Sphinx build is configured in .readthedocs.yaml:
version: 2
build:
os: ubuntu-24.04
tools:
python: '3.13'
commands:
- asdf plugin add uv
- asdf install uv latest
- asdf global uv latest
- uv run --group docs sphinx-build -W -b dirhtml docs $READTHEDOCS_OUTPUT/html
The -W flag turns Sphinx warnings into errors; the dirhtml builder
produces index.html per directory rather than .html files at every
level. Documentation source lives in docs/ (Sphinx RST: index.rst,
escaping.rst, formatting.rst, html.rst, changes.rst, license.rst).
4. Deployment & Scaling
MarkupSafe is a library. There is nothing to deploy.
In practical terms, "deploying MarkupSafe" means two activities:
- Installing it as a transitive dependency of a web application.
- Choosing whether the C accelerator is present in the deployment image.
Containerized deployment
In a containerized application, MarkupSafe is installed by the same step that installs Flask/Jinja2/whatever:
FROM python:3.13-slim
COPY requirements.txt .
RUN pip install -r requirements.txt
Because PyPI ships prebuilt wheels for every mainstream platform/arch,
the C accelerator is normally present in the resulting image without any
build dependencies. If you're building inside a python:3.13-slim image
without gcc, you still get the C accelerator because pip resolves to a
wheel, not a source distribution.
Source-build environments (uncommon)
If your environment forces a source install (pip install --no-binary :all:,
some restricted enterprise indexes, or air-gapped deployments without
prebuilt wheels), include build dependencies:
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc python3-dev \
&& rm -rf /var/lib/apt/lists/*
The build is fast (a few seconds) and produces a single .so of a few
kilobytes. If the build fails, the package still installs in pure-Python
mode — see Section 6 for how to detect which is active.
Scaling considerations
MarkupSafe escaping is CPU-bound, completely synchronous, and has no
shared state. It scales linearly with whatever is calling it. There are
no connection pools, no caches, no rate limits, no distributed concerns.
For a typical web request, escape time is dominated by everything else
(database, template rendering, network), and escape() does not appear
in profiling output.
Tip
If a profile does show MarkupSafe high on the flame graph, the typical cause is escaping the same large string many times in a tight loop. Cache the
Markup-typed result and reuse it; once a value isMarkup, subsequentescape()calls hit the__html__short-circuit and return self without re-walking the bytes.
Compatibility with sub-interpreters and free-threading
MarkupSafe declares both Py_mod_per_interpreter_gil_supported and
Py_mod_gil_not_used. Applications running on Python 3.13's free-threaded
build (--disable-gil) can use MarkupSafe across threads without the GIL
without a correctness compromise. Applications using concurrent.interpreters
or PEP 684 sub-interpreters get an isolated module per interpreter
without explicit configuration.
5. Operations
There is no runtime to monitor — no logs, no metrics endpoints, no admin commands. Operational concerns reduce to "is the right implementation in use?" and "does the test suite pass?"
Verifying the active implementation
import markupsafe._speedups # ImportError ⇒ pure-Python fallback in use
import markupsafe
markupsafe._escape_inner.__module__
# 'markupsafe._speedups' -- C accelerator active
# 'markupsafe._native' -- pure-Python fallback active
Note
In production health checks, prefer the second form. It tells you which code path the running process actually bound at import time, regardless of which modules are importable now.
Running the test suite
The test suite lives in tests/:
| Test file | Coverage |
|---|---|
tests/test_markupsafe.py |
Markup class behaviour, operators, formatting. |
tests/test_escape.py |
escape, escape_silent, soft_str. |
tests/test_exception_custom_html.py |
Behaviour when __html__ raises. |
tests/test_ext_init.py |
C-extension import / fallback path. |
tests/test_leak.py |
Reference-count regression checks (the C path is verified not to leak). |
tests/conftest.py |
Pytest fixtures shared across tests. |
uv run pytest # full suite
uv run pytest -k speedups # just the C-extension tests
uv run pytest -x --ff # stop on first fail, fail-first reorder
Tip
When auditing the binary path specifically, run the suite once with
_speedupsimportable and once with it deliberately broken (rename the compiled.sotemporarily). Both runs must pass — equivalence of the two implementations is the central correctness invariant of the project.
Reading benchmark output
uv run python bench.py
bench.py runs pyperf timeit for five string shapes (short escape,
long escape, short plain, long plain, long suffix) against both
implementations. Output is per-iteration timings; compare lines with the
same name prefix to see the speedups multiplier.
Upgrade procedure
MarkupSafe follows semver via the Pallets release process. To upgrade:
pip install --upgrade markupsafe
# or
uv lock --upgrade-package markupsafe
Read CHANGES.rst for the release notes. Because MarkupSafe is a
transitive dependency of Jinja2, Flask, etc., a major-version bump is
typically driven by an upstream framework upgrade rather than a direct
choice. Test the application end-to-end after bumping — the API is
extremely stable, but XSS defence is the kind of thing you want
confirmed by your own integration tests.
Note
Past major upgrades have been small in scope (e.g. dropping legacy Python versions, switching the default
escape()from a function to a classmethod path). Backward compatibility for the public surface (escape,Markup,__html__) has been preserved for years.
Pinning strategy
Because MarkupSafe is on the security path, pin a minimum version (e.g.
markupsafe>=2.1) rather than an exact version, so security fixes
propagate through transitive resolution without requiring a manual bump.
For reproducibility, lock with uv.lock or pip-compile.
6. Performance
The performance story is a single sentence: there are two implementations
of _escape_inner, the C one is faster on long strings, both are correct.
The two implementations
# src/markupsafe/_native.py (the entire file, 7 lines)
def _escape_inner(s: str, /) -> str:
return (
s.replace("&", "&")
.replace(">", ">")
.replace("<", "<")
.replace("'", "'")
.replace('"', """)
)
The pure-Python path is a five-pass str.replace. Each call walks the
input once, allocating a new string per replacement. For long strings
that contain many of these characters, the allocation cost adds up.
The C path (_speedups.c) is a single-pass scan with a precomputed
delta. The algorithm is:
- Pass 1 (
GET_DELTAmacro): walk the input, computing how many extra bytes the escaped form will need.&,',"add 4;<,>add 3. - Allocation: if delta is zero, return the input unchanged with
Py_INCREF(zero-copy fast-path for clean strings). Otherwise allocate a newPyUnicodeObjectexactly the right size. - Pass 2 (
DO_ESCAPEmacro): walk the input again, copying runs of plain characters withmemcpyand writing entity replacements in-place at the output cursor.
The macro is specialized three times — escape_unicode_kind1,
escape_unicode_kind2, escape_unicode_kind4 — corresponding to
PyUnicode_1BYTE_KIND, PyUnicode_2BYTE_KIND, PyUnicode_4BYTE_KIND.
The dispatcher escape_unicode reads PyUnicode_KIND(s) and selects.
Note
The 1-byte path additionally chooses the output's max-codepoint based on whether the input is ASCII (
PyUnicode_IS_ASCII(in) ? 127 : 255). This keeps the output as compact as possible — an all-ASCII input stays all-ASCII even after escaping, avoiding a needless promotion to Latin-1 storage.
Measured speedup
bench.py is the canonical benchmark. Representative speedups on
CPython 3.13 (orders-of-magnitude only — exact numbers vary by hardware):
| Benchmark | Native (Python) | Speedups (C) | Ratio |
|---|---|---|---|
short escape (a small HTML string) |
~µs | ~ns | several × |
long escape (1000-rep Hello, World!) |
~ms | ~µs | substantial |
short plain (no special chars) |
~ns | ~ns (fast Py_INCREF no-copy) |
comparable |
long plain (1000-rep, no special chars) |
~µs | ~ns (delta=0 fast path) | substantial |
long suffix (HTML + 100K plain bytes) |
~ms | ~µs | substantial |
Important
The "delta == 0" fast path is the most operationally important optimization. Most strings passed through
escape()in a real application contain none of the five special characters (think: usernames, IDs, ASCII-only labels). For these, the C path is a single walk with no allocation; the pure-Python path is five wastedstr.replaceallocations. If you need MarkupSafe to be fast in production, get the C accelerator.
Hot-path engineering details
| Concern | Mitigation in __init__.py |
|---|---|
Avoid double-escape on Markup |
escape() checks type(s) is str first. Markup (a str subclass) misses the fast path and goes through __html__. |
Avoid __class__ lookup spoofing |
escape() uses type(s) not s.__class__ — defends against proxy objects that fake their class. |
Avoid extra __html__ call cost on plain str |
The type(s) is str short-circuit means hasattr(s, "__html__") is only invoked for non-strs. |
| Preserve subclass through operators | Every operator/method routes returns through self.__class__(...) rather than literal Markup(...). |
Memory model
Markup declares __slots__ = (). Instances have no per-object dict
and the same memory footprint as a regular str. The class adds zero
bytes to existing string allocations.
_MarkupEscapeHelper and EscapeFormatter also declare __slots__,
which keeps the printf-style % formatting path allocation-light.
7. Security
This IS the XSS-defence library. This section is the heart of the guide.
Threat model
The threat MarkupSafe addresses is a single, well-defined one:
An attacker submits data to a server. The server interpolates that data into an HTML response. The browser interprets the data as markup rather than text. The attacker's payload executes in the victim's session.
This is reflected/stored Cross-Site Scripting (XSS). MarkupSafe's job is to prevent the second-to-last step — to ensure that anything substituted into HTML output is treated as text by every conformant browser, not markup.
Defence: the five characters
& --> &
< --> <
> --> >
" --> "
' --> '
These five replacements are sufficient to prevent every standards-defined HTML/XML interpretation of an interpolated string. Specifically:
<and>cannot start or end a tag."and'cannot break out of a quoted attribute value.&cannot start a numeric or named entity that decodes to one of the above.
Anything else (raw newlines, NUL bytes, control characters, Unicode in the astral planes) is permitted in HTML body and quoted attribute contexts and does not need escaping for security purposes.
Caution
Five-character HTML escaping is not sufficient for unquoted attributes, JavaScript string contexts, URL components, CSS values, or the contents of
<script>,<style>, or<textarea>elements. MarkupSafe assumes a quoted-attribute or body-text destination. Usingescape()to produce data for any other context is a security mistake; use a context-appropriate escaper instead.
Trust boundaries
MarkupSafe partitions strings into two type-tagged sets:
- Trusted: any value that is an instance of
Markup. The contract is that the bytes are already safe for direct HTML insertion. - Untrusted: any value that is not a
Markup. The contract is that the value must be escaped (or its__html__method invoked) before reaching HTML output.
This is enforced positively: every Markup operator (+, %, *,
format, format_map, join, replace, etc.) escapes its
non-Markup operands before combining. There is no operator that takes
an untrusted string and produces an unescaped Markup — the only
construction routes are:
| Route | Result | Trust meaning |
|---|---|---|
Markup(literal) |
Markup |
Caller asserts safety (no escaping). |
escape(value) |
Markup |
Library guarantees safety (escaped). |
value.__html__() then wrap |
Markup |
Object asserts safety (no escaping of return). |
Operator on Markup + str |
Markup |
str operand is escaped first; result safe. |
Misuse pattern #1: calling Markup() on user input
This is the dominant XSS bypass in code that uses MarkupSafe.
# WRONG — every character of user_input is rendered as markup.
return Markup(request.form["name"])
# WRONG — Jinja2 receives a Markup, autoescape skips the escape step.
render_template("page.html", name=Markup(request.form["name"]))
# RIGHT — let escape() handle it (or let Jinja2 autoescape do so).
return escape(request.form["name"])
render_template("page.html", name=request.form["name"])
Caution
The
Markup()constructor is the trust-asserting form. It is for programmer-authored HTML — layout snippets, icons, vetted CMS output. Whenever you read code that callsMarkup()on a value that traces back to a request, header, query parameter, file upload, or third-party API, treat it as a presumed XSS bug. Pair it with a sanitizer or rewrite toescape().
Misuse pattern #2: Jinja2's | safe filter on tainted data
{{ name | safe }} is the in-template equivalent of Markup(name).
Same defect, same severity.
{# WRONG #}
<h1>{{ user.name | safe }}</h1>
{# RIGHT — autoescape handles it. The `| safe` is unnecessary AND dangerous. #}
<h1>{{ user.name }}</h1>
Grep templates for | safe and |safe; every match should be
demonstrably trusted.
Misuse pattern #3: format spec that smuggles unsafe content
# WRONG — value's str() goes through plain-text alignment, then gets
# wrapped as a literal Markup instead of being escaped.
Markup("{:>10}").format(user_input)
This particular case is now handled: Markup.format routes through
EscapeFormatter, which calls escape() on every field value. But the
general pattern — "I'll just convert to str and reuse it" — is a
recurring source of bugs in extensions to MarkupSafe. The lesson: never
construct a Markup from a str that wasn't itself produced by
escape() or by a value's verified __html__ method.
Misuse pattern #4: __html__ that doesn't escape
# WRONG — interpolates user data into HTML without escaping.
class BadProfile:
def __html__(self):
return f'<a href="/u/{self.username}">{self.bio}</a>'
# RIGHT — every interpolation is escaped via Markup.format.
from markupsafe import Markup
class GoodProfile:
def __html__(self):
return Markup('<a href="/u/{0}">{1}</a>').format(self.username, self.bio)
When you implement __html__, the library trusts your return value
absolutely. Authors of __html__ methods carry the same responsibility
as authors of layout templates — if you interpolate, you escape.
Misuse pattern #5: f-strings around Markup
# WRONG — f-string interpolation produces a plain str, losing the
# Markup tag. Subsequent escape() calls double-encode.
m = escape(user_data)
result = f"<div>{m}</div>" # type(result) is str, not Markup
# RIGHT — keep the Markup identity end-to-end.
result = Markup("<div>") + m + Markup("</div>")
result = Markup("<div>{}</div>").format(user_data) # cleaner
The defect here is not strictly an XSS — the bytes are correct as long as nothing re-escapes — but it's a footgun in any pipeline that does its own escaping pass.
Known historical issues
The briefing did not surface any GitHub issues for this repository, and
no error_messages.tsv rows reference CVE entries. MarkupSafe has had a
small number of historical security-relevant fixes (notably around the
escape fast-path for proxy objects, which is why the current code uses
type(s) is str rather than isinstance — see Section 6). Verify the
release notes (CHANGES.rst) for the version you're deploying; new
findings are documented there as part of the Pallets project's
disclosure process.
Warning
Do not assume "no issues fetched" means "no security history." It means the briefing's issue fetcher did not find an open issue tracker for this repository at extraction time. The Pallets project tracks security advisories through the GitHub Security Advisory mechanism on the repository — review those when triaging a deployment. Always upgrade promptly when an advisory affects MarkupSafe specifically.
Defensive coding checklist
- [ ] Every call site that constructs a
Markupfrom an external string has been audited. - [ ] Every Jinja2 template's
| safefilter usage is justified in review comments. - [ ]
__html__implementations escape every interpolation viaMarkup.formator%. - [ ]
escape_silentis used whereverNoneis a possible value. - [ ] HTML embedded in JSON, JS, URLs, or CSS is escaped with a
context-appropriate function, not
escape(). - [ ]
striptags()is not used as a sanitizer for re-display — only for reduction to plain text. - [ ] Production process imports
markupsafe._speedups._escape_innersuccessfully (if performance matters).
8. Developer Internals
This section documents how the library is structured for contributors, auditors, and library authors who need to interoperate at the protocol level.
Repository structure
markupsafe/
src/markupsafe/
__init__.py # public API + Markup class (~380 lines)
_native.py # pure-Python _escape_inner (7 lines)
_speedups.c # C accelerator (~200 lines)
_speedups.pyi # type stubs for the compiled extension
py.typed # PEP 561 marker
tests/
__init__.py
conftest.py
test_escape.py
test_exception_custom_html.py
test_ext_init.py
test_leak.py
test_markupsafe.py
docs/
Makefile, conf.py, index.rst, escaping.rst,
formatting.rst, html.rst, changes.rst, license.rst,
_static/ # logo + icon SVGs
bench.py # pyperf-driven benchmark
setup.py # optional C extension build
pyproject.toml # build backend + tool config
uv.lock # locked dev dependencies
.pre-commit-config.yaml # ruff + uv-lock + hygiene hooks
.readthedocs.yaml # Sphinx via uv on RTD
.editorconfig
.gitignore
CHANGES.rst # release notes
LICENSE.txt # BSD-3-Clause
MANIFEST.in
README.md
Module reference: src/markupsafe/__init__.py
This is the entire public API. Functions and classes confirmed via the extracted index:
Module-level names
| Name | Kind | Lines | Description |
|---|---|---|---|
_HasHTML |
Protocol |
16-19 | Type-only protocol describing objects with __html__. |
_TPEscape |
Protocol |
20-23 | Type-only protocol describing the escape callable. |
escape |
function | 24-46 | Make a value HTML-safe; returns Markup. |
escape_silent |
function | 48-62 | escape() that maps None to Markup(''). |
soft_str |
function | 64-83 | Coerce to str without unwrapping Markup. |
Markup |
class | 84-330 | The tagged-string class. |
EscapeFormatter |
class | 332-355 | string.Formatter subclass that escapes every field. |
_MarkupEscapeHelper |
class | 357-381 | Per-operand wrapper used by Markup.__mod__. |
_escape_inner |
imported | 9-12 | Selected from _speedups (C) or _native (Python). |
Markup class — methods inventory
The Markup class declares every str method that needs special
handling to preserve the safety guarantee. Each is confirmed in the
extracted function index.
| Method | Returns | Behaviour |
|---|---|---|
__new__(cls, object="", encoding=None, errors="strict") |
Markup |
Calls __html__ if present; otherwise wraps without escaping. |
__html__() |
Markup (self) |
Idempotent — already safe. |
__add__(value) |
Markup or NotImplemented |
Escapes str/__html__ operand; rejects others. |
__radd__(value) |
Markup or NotImplemented |
Mirror of __add__. |
__mul__(value), __rmul__(value) |
Markup |
String repeat. |
__mod__(value) |
Markup |
Printf via _MarkupEscapeHelper-wrapped operand(s). |
__repr__() |
str |
Markup('...'). |
join(iterable) |
Markup |
Escapes each item before joining. |
split, rsplit, splitlines |
list[Markup] |
Preserves type on each chunk. |
unescape() |
str |
html.unescape of self. |
striptags() |
str |
Comments stripped, tags stripped, whitespace collapsed, then unescape. |
escape(s) (classmethod) |
cls |
Module-level escape(), but returns the calling subclass. |
__getitem__(key) |
Markup |
Slicing returns Markup. |
capitalize, title, lower, upper, casefold, swapcase, strip, lstrip, rstrip, expandtabs, zfill, removeprefix, removesuffix |
Markup |
Plain delegation, wrapped. |
replace(old, new, count=-1) |
Markup |
Escapes new before delegating. |
ljust, rjust, center |
Markup |
Escapes the fill character. |
translate(table) |
Markup |
Wrapped. |
partition(sep), rpartition(sep) |
tuple[Markup, Markup, Markup] |
Each piece wrapped. |
format(*args, **kwargs) |
Markup |
Routes through EscapeFormatter. |
format_map(mapping) |
Markup |
Same. |
__html_format__(format_spec) |
Markup (self) |
Rejects non-empty spec with ValueError. |
Markup declares __slots__ = () — instances are byte-equivalent to
plain str.
EscapeFormatter class
Subclass of string.Formatter with one slot (escape). Overrides
format_field(value, format_spec):
- If
valuehas__html_format__, call it with the spec. - Else if
valuehas__html__:- Spec empty → use
value.__html__(). - Spec non-empty → raise
ValueError(must define__html_format__).
- Spec empty → use
- Otherwise → delegate to
super().format_field(value, str(format_spec)). - Wrap the result with
self.escape(rv).
_MarkupEscapeHelper class
Used internally by Markup.__mod__ to wrap operands before printf-style
substitution. Wraps the underlying value and an escape callable;
__getitem__, __str__, __repr__, __int__, __float__ all route
through escape so any access produces the safe form. Three slots:
obj, escape. Not exported and not part of the public API.
src/markupsafe/_native.py — pure-Python escaper
The whole file:
def _escape_inner(s: str, /) -> str:
return (
s.replace("&", "&")
.replace(">", ">")
.replace("<", "<")
.replace("'", "'")
.replace('"', """)
)
Order is important: & first so the & produced by later
replacements does not double-encode. The pure-Python path is correct,
just allocation-heavy on long inputs.
src/markupsafe/_speedups.c — C accelerator
Two macros (GET_DELTA, DO_ESCAPE) define the algorithm; three
specializations (escape_unicode_kind1/2/4) bind it to each
PyUnicode_KIND; one dispatcher (escape_unicode) selects the kind.
Module is exported with two slots — Py_mod_multiple_interpreters and
Py_mod_gil — declaring per-interpreter-GIL and free-threading
compatibility.
The C code uses only the public CPython API:
PyUnicode_Check, PyUnicode_READY (3.12 no-op),
PyUnicode_KIND, PyUnicode_GET_LENGTH,
PyUnicode_New,
PyUnicode_1BYTE_DATA, PyUnicode_2BYTE_DATA, PyUnicode_4BYTE_DATA,
PyUnicode_IS_ASCII,
Py_INCREF. No private headers are required.
The exported method table:
static PyMethodDef module_methods[] = {
{"_escape_inner", (PyCFunction)escape_unicode, METH_O, NULL},
{NULL, NULL, 0, NULL}
};
METH_O means the function takes exactly one positional argument (the
string to escape). Type checking happens inside escape_unicode —
non-str input returns NULL, raising the standard "argument must be
str" via the caller. The Python wrapper in __init__.py always passes a
str, so this branch is defensive.
Build system: setup.py
ext_modules = [Extension("markupsafe._speedups", ["src/markupsafe/_speedups.c"])]
class BuildFailed(Exception): ...
class ve_build_ext(build_ext):
def run(self): ...
def build_extension(self, ext): ...
def run_setup(with_binary): ...
def show_message(*lines): ...
supports_speedups = platform.python_implementation() not in {"PyPy", "Jython", "GraalVM"}
if os.environ.get("CIBUILDWHEEL", "0") == "1" and supports_speedups:
run_setup(True) # build, fail hard
elif supports_speedups:
try:
run_setup(True)
except BuildFailed:
... # re-run without binary, print warning
run_setup(False)
else:
run_setup(False) # no binary on PyPy/Jython/GraalVM
Key contract: BuildFailed is raised from any of CCompilerError,
ExecError, PlatformError, or a 'path'-mentioning ValueError
(Windows-64 workaround for Python issue 7511). Anything else propagates.
Testing strategy
| Layer | What it checks |
|---|---|
test_escape.py |
Output of escape, escape_silent, soft_str for representative inputs. |
test_markupsafe.py |
Markup operators, formatting, string-method preservation. |
test_exception_custom_html.py |
Behaviour when __html__ raises (the exception propagates; no swallowing). |
test_ext_init.py |
The C-extension import-or-fallback selector in __init__.py:9-12. |
test_leak.py |
The C path's reference counting. Run with a gc.collect() baseline; counts must not climb. |
Note
The two implementations of
_escape_innerare tested by the same assertions. This is deliberate — they must produce byte-identical output for any input. A divergence is a release-blocker.
Code patterns and conventions
__slots__ = ()on every class — keeps instances allocation-thin and prevents accidental attribute additions on hot-path objects.type(s) is strrather thanisinstance(s, str)— defends against proxy objects that report a faked class. Documented as a comment onescape.- Positional-only parameters (
/) — every public function uses positional-only for the value being escaped, preventing keyword-name collisions and making the signature stable across implementations. __html__is a one-method protocol — the library never asks for more from a value than that method, keeping the duck-typing surface minimal.- Order of replacements in
_native—&first. Same constraint is structurally upheld in_speedups.cbecause each character is handled exactly once in the single pass. - Subclass-preserving
self.__class__— every operator wraps the result inself.__class__(...), soMyMarkupSubclass + "x"returnsMyMarkupSubclass, notMarkup.
Potential improvements
The library is mature and small; substantive changes are rare. Possible future work that would not break compatibility:
- Free-threaded benchmark:
bench.pydoes not yet exercise the GIL-disabled CPython 3.13 build. Add a configuration that runs the same five benchmarks with--disable-gilto verify thePy_mod_gil = Py_MOD_GIL_NOT_USEDclaim under load. - Hypothesis fuzzing: the test suite uses example-based pytest
cases. Property-based fuzzing of "for any
s,escape(s)produces aMarkupwhoseunescape()round-trips" would be a natural addition, in particular around astral-plane Unicode. __html_format__ergonomics: requiring users to implement__html_format__whenever they implement__html__and might be used with format specs is a minor footgun. A default mixin that format-specs the__html__()output and re-wraps would simplify custom__html__-bearing classes.- PyPy speedups path: the C extension is unconditionally skipped on PyPy because of past JIT-vs-extension performance regressions. PyPy's modern JIT may render the pure-Python path competitive without intervention, but a benchmark to confirm and a documented expectation would be useful.
Contributing
The Pallets project takes contributions through pull requests on GitHub. The local development cycle is:
git clone https://github.com/pallets/markupsafe.git
cd markupsafe
uv sync # install with dev deps
pre-commit install # set up the lint hooks
uv run pytest # baseline
# ... make changes ...
uv run pytest # verify
git commit # ruff + uv-lock hooks run
Pre-commit hooks (Section 3) run automatically on commit; CI re-runs
them. Documentation changes go in docs/*.rst and are previewed via
uv run --group docs sphinx-build -W -b dirhtml docs _build/html.
License: BSD-3-Clause. The full text is in LICENSE.txt.
9. Integration Points
Jinja2 (the canonical consumer)
When a Jinja2 environment is created with autoescape=True (the default
for flask.render_template and any Environment(autoescape=...) with
a true value), every {{ expression }} substitution runs:
- Evaluate the expression to a value.
- Call
markupsafe.escape(value)on the result. - Insert the resulting
Markupinto the output.
The | safe filter is implemented as Markup(value) — a programmer's
assertion that the value is already safe. The | escape (or | e)
filter is escape(value) directly. Both are part of the public
Jinja2/MarkupSafe contract; they have not changed signature in years.
Flask
Flask uses Jinja2's autoescape by default for templates rendered via
render_template and render_template_string. flask.Markup is a
re-export of markupsafe.Markup, kept for backward compatibility.
Custom template engines
Any template engine targeting Python web output that needs HTML/XML escaping should depend on MarkupSafe rather than rolling its own. Concrete integration points:
- Use
markupsafe.escape(value)as the escape primitive. - Accept
markupsafe.Markupinstances as "trust-me" markers. - Detect user-defined safe types via
hasattr(value, "__html__"). - For format-spec support inside the engine, route through
markupsafe.EscapeFormatter(it is exported and stable).
Domain models
Application domain classes integrate by implementing __html__:
class Comment:
body: str
def __html__(self):
from markupsafe import Markup
return Markup("<p>{}</p>").format(self.body)
The same object is then usable in any consumer that calls
markupsafe.escape() — Jinja2 templates, Flask responses, custom
renderers — without further work.
JSON
Markup is a str subclass, so json.dumps(my_markup) serializes the
underlying characters as a normal JSON string. Safety markers do not
survive JSON. If you need to embed safe HTML inside JSON inside a
<script> tag, use Flask's tojson filter or equivalent — plain
json.dumps is not sufficient for the script-context case (a </
sequence in the JSON would terminate the surrounding <script>).
XML / SVG / RSS / Atom
The five-character set covers XML 1.0 just as it does HTML5 (XML
canonicalization treats ' and " identically in attribute values).
SVG, RSS, and Atom are XML; MarkupSafe's escape() is appropriate for
their text and attribute content. Be aware that XML has additional
contexts (CDATA sections, processing instructions, DOCTYPE) where
escaping is not what you want — for those, write the literal markup
directly via Markup.
What MarkupSafe does NOT integrate with
| Context | Use instead |
|---|---|
| URL components | urllib.parse.quote / quote_plus |
| URL query strings (whole) | urllib.parse.urlencode |
| Shell commands | shlex.quote (POSIX), subprocess with args=list (don't shell) |
| JavaScript string literals | json.dumps plus careful </script> handling |
| CSS values | A dedicated CSS sanitizer; CSS context escaping is non-trivial |
| Email headers | email.headerregistry from the standard library |
| LDAP filters | ldap3.utils.conv.escape_filter_chars |
| SQL | Parameterized queries (database driver), never string concatenation |
Important
The single most common production XSS bug in MarkupSafe-using code bases is reaching for
escape()to sanitize a value going into a non-HTML context (a URL, a JS literal, a CSS rule, an email header). Train every developer who touches the code:markupsafe.escape= HTML/XML body or quoted attribute, only.