Jesse A. Green / notebook

MarkupSafe Administrator & Developer 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 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_points group, no monkey-patch hooks, no global state. The single dispatch decision — "C accelerator or Python fallback?" — is made exactly once, at import time, by the try / except ImportError block 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 by Markup(...) (asserted-safe), escape(...) (made-safe), or any operator/method on an existing Markup.
  • Untrusted (any other str or __html__-less object) — values that must pass through escape() 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_USED on the free-threaded build (PEP 703) is a deliberate, audited claim. The _escape_inner C function is purely functional — it allocates a fresh PyUnicodeObject, 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-running pre-commit autoupdate after 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:

  1. Installing it as a transitive dependency of a web application.
  2. 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 is Markup, subsequent escape() 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 _speedups importable and once with it deliberately broken (rename the compiled .so temporarily). 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("&", "&amp;")
        .replace(">", "&gt;")
        .replace("<", "&lt;")
        .replace("'", "&#39;")
        .replace('"', "&#34;")
    )

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:

  1. Pass 1 (GET_DELTA macro): walk the input, computing how many extra bytes the escaped form will need. &, ', " add 4; <, > add 3.
  2. Allocation: if delta is zero, return the input unchanged with Py_INCREF (zero-copy fast-path for clean strings). Otherwise allocate a new PyUnicodeObject exactly the right size.
  3. Pass 2 (DO_ESCAPE macro): walk the input again, copying runs of plain characters with memcpy and 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 wasted str.replace allocations. 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

&  -->  &amp;
<  -->  &lt;
>  -->  &gt;
"  -->  &#34;
'  -->  &#39;

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. Using escape() 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 calls Markup() 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 to escape().

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 Markup from an external string has been audited.
  • [ ] Every Jinja2 template's | safe filter usage is justified in review comments.
  • [ ] __html__ implementations escape every interpolation via Markup.format or %.
  • [ ] escape_silent is used wherever None is 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_inner successfully (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):

  1. If value has __html_format__, call it with the spec.
  2. Else if value has __html__:
    • Spec empty → use value.__html__().
    • Spec non-empty → raise ValueError (must define __html_format__).
  3. Otherwise → delegate to super().format_field(value, str(format_spec)).
  4. 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("&", "&amp;")
        .replace(">", "&gt;")
        .replace("<", "&lt;")
        .replace("'", "&#39;")
        .replace('"', "&#34;")
    )

Order is important: & first so the &amp; 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_inner are 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 str rather than isinstance(s, str) — defends against proxy objects that report a faked class. Documented as a comment on escape.
  • 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.c because each character is handled exactly once in the single pass.
  • Subclass-preserving self.__class__ — every operator wraps the result in self.__class__(...), so MyMarkupSubclass + "x" returns MyMarkupSubclass, not Markup.

Potential improvements

The library is mature and small; substantive changes are rare. Possible future work that would not break compatibility:

  • Free-threaded benchmark: bench.py does not yet exercise the GIL-disabled CPython 3.13 build. Add a configuration that runs the same five benchmarks with --disable-gil to verify the Py_mod_gil = Py_MOD_GIL_NOT_USED claim under load.
  • Hypothesis fuzzing: the test suite uses example-based pytest cases. Property-based fuzzing of "for any s, escape(s) produces a Markup whose unescape() 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:

  1. Evaluate the expression to a value.
  2. Call markupsafe.escape(value) on the result.
  3. Insert the resulting Markup into 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.Markup instances 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.