In Django, why should a helper that builds HTML from user data use format_html() rather than mark_safe() around an f-string?
answer
- a label, not a transformation
- which part gets escaped
- arguments versus the format string
- safe arguments pass through untouched
- joining many rows at once
basics
~10 smark_safe() only labels a string as safe without escaping anything, so user data interpolated by an f-string reaches the page raw; format_html() escapes each argument with conditional_escape and then marks the combined result safe.
solid answer
~40 s`mark_safe(s)` returns a `SafeString`: it changes nothing in the text, it only tells the template engine not to escape it. Wrapping `f'<span title="{review.author_name}">'` in it therefore ships whatever the reviewer typed straight into the HTML. `format_html(format_string, *args, **kwargs)` works like `str.format`, but passes every argument through `conditional_escape()` first and marks only the final result safe, so the literal markup you wrote survives and the data is escaped. Arguments that are already safe, such as another `format_html()` result, pass through unchanged, which lets helpers compose. For lists there is `format_html_join(sep, format_string, args_generator)`, which accepts tuples and, since 5.2, mappings. Since Django 6.0, calling `format_html()` with no arguments raises `TypeError`; for a constant string use `mark_safe()`.
code
python · 17 linesfrom django.utils.html import format_html, format_html_join
def rating_badge(review):
return format_html(
'<span class="rating" title="{}">{} / 5</span>',
review.author_name,
review.rating,
)
def tag_list(review):
return format_html_join(
', ',
'<a href="/reviews/?tag={slug}">{label}</a>',
({'slug': t.slug, 'label': t.label} for t in review.tags.all()),
)go deeper
Remember that mark_safe does not escape anything; it only tells Django to stop escaping, so never wrap user data in it.
Explain how format_html runs conditional_escape on each argument, marks the result safe, and lets safe fragments compose without double escaping.
Audit helpers for mark_safe around interpolated strings, SafeString concatenation that silently loses safety, and f-strings passed to format_html.
Set a codebase rule that data-bearing HTML is built only with format_html or templates, and make mark_safe on non-constant input a review blocker.
## Two tools, two very different jobs On a product-review site you often build small HTML fragments in Python: a star-rating badge, an author link, a list of pros and cons. Django gives you two functions in `django.utils` for this, and they are easy to confuse. | Function | Escapes anything? | Marks result safe? | Use for | |---|---|---|---| | `django.utils.safestring.mark_safe(s)` | no | yes | a string you fully control, such as a constant | | `django.utils.html.format_html(fmt, *args, **kwargs)` | every argument | yes | markup that interpolates data | | `django.utils.html.format_html_join(sep, fmt, args_generator)` | every argument and `sep` | yes | repeating the same markup per item | A **safe string** is a `SafeString` — a `str` subclass that the template engine will not autoescape. Marking is a **promise by the caller** that the text is already correct HTML. ## Why mark_safe around an f-string is an XSS bug ```python from django.utils.safestring import mark_safe def author_badge(review): # BUG: author_name is user-controlled and is never escaped return mark_safe(f'<span class="author">{review.author_name}</span>') ``` The f-string inserts `author_name` verbatim, and `mark_safe` then tells the template not to escape the result. A reviewer named `<img src=x onerror=alert(1)>` now executes script for every visitor. The problem is not `mark_safe` itself; it is marking a string that contains data you did not escape. ## How format_html fixes it ```python from django.utils.html import format_html def author_badge(review): return format_html('<span class="author">{}</span>', review.author_name) ``` `format_html()`: 1. passes each positional and keyword argument through **`conditional_escape()`**, which escapes plain strings and leaves values with an `__html__` method (safe strings) untouched, 2. formats them into the format string with `str.format` semantics, 3. marks the whole result safe. The markup you wrote in the format string is trusted; the data is escaped. Because safe arguments pass through, helpers compose: `format_html('<li>{}</li>', author_badge(review))` does not double-escape the inner badge. ## format_html_join for repeated markup ```python from django.utils.html import format_html, format_html_join def pros_list(review): items = format_html_join('\n', '<li>{}</li>', ((p,) for p in review.pros)) return format_html('<ul class="pros">{}</ul>', items) ``` `args_generator` yields a tuple of positional arguments per item; since **Django 5.2** it may instead yield mappings, which are passed as keyword arguments. The separator is escaped too. ## Traps around safe strings - **Concatenation drops safety.** `SafeString + SafeString` stays safe, but `SafeString + str` returns a plain `str`, which the template then escapes, so your markup appears as literal `<span>` text. The usual wrong fix is to wrap the concatenation in `mark_safe()`, which re-opens the hole if the plain part held data. Build the whole fragment with `format_html()` instead. - **Formatting before marking.** `format_html()` must receive the data as arguments; passing an already-interpolated string (`format_html(f'...{name}...')`) escapes nothing. Since Django 6.0 a call with no arguments raises `TypeError`, which catches the most common version of this mistake. - **`mark_safe` as a decorator** marks a function's return value safe; the same rule applies to what the function interpolates. ## A rule of thumb - Constant markup: `mark_safe()` is fine. - Markup plus data: `format_html()`. - Markup repeated over data: `format_html_join()`. - Arbitrary user-supplied HTML: neither — that needs an allowlist sanitizer, and only its output may be marked safe.
- A helper returns mark_safe('<b>') + review.title and the template shows <b>. Why?Adding a plain `str` to a `SafeString` returns a plain `str`; the safe marker is lost, so autoescaping encodes the whole result, including your tag. Build the fragment with `format_html('<b>{}</b>', review.title)`, which escapes the title and returns a safe string.
- What happens in Django 6.1 if you call format_html('<hr>') with no arguments?It raises `TypeError`. Calling `format_html()` without args or kwargs was deprecated in 5.0 and removed in 6.0, because it usually meant the data had already been interpolated. Use `mark_safe('<hr>')` for a constant.
saying these in an interview costs you the question
- mark_safe escapes the string and then marks it safe.
- format_html escapes the format string as well as the arguments.
- format_html double-escapes an argument that is already a safe string.
- Adding a plain str to a SafeString keeps the result safe.
- Passing an f-string to format_html is as safe as passing arguments.