In Django you mark a string as safe with django.utils.safestring.mark_safe(), or in a template with the safe filter. Both tell the template engine to skip HTML escaping for that value. In almost every real case you should reach for django.utils.html.format_html() instead, because it escapes your arguments for you and only marks the finished fragment as safe. Django’s own documentation says as much: for building up fragments of HTML, you should normally be using format_html().
format_html("<span class='badge'>{}</span>", label) in views, model methods and admin display methods. Use mark_safe() only on a string you built entirely yourself with no variable data in it. In templates use {{ value|safe }} only when the value came from a trusted source, never for anything a user typed.The reason this matters is that Django’s autoescaping is the main thing standing between a comment field and a cross site scripting hole. Turning it off is a decision, not a formatting convenience, and the API is designed so that the safe and the dangerous approaches look almost identical in a diff. Knowing which of the four mechanisms you are using, and why, is most of the job.
What Django escapes by default
Every variable rendered in a Django template is passed through the escaper unless something has marked it safe. Five characters are replaced.
| Character | Becomes |
|---|---|
| < | < |
| > | > |
| & | & |
| Double quote | " |
| Single quote | ' |
So a comment body containing a script tag renders as visible text rather than executing. That default is what you are overriding whenever you mark something safe, and you are overriding it for the whole value, not for the parts you meant to allow.
The four mechanisms, and when each applies
| Mechanism | Escapes your data? | Use it when |
|---|---|---|
format_html() | Yes, every argument | Always, when building HTML in Python |
format_html_join() | Yes, plus the separator | Repeating one fragment over an iterable |
mark_safe() | No, nothing | A literal you wrote with no interpolation at all |
{{ value|safe }} | No, nothing | The value is already trusted HTML from your own code |
Why format_html is the right default
Its contract is precise. The first argument, the format string, is not escaped. Every other positional and keyword argument goes through conditional_escape() before it reaches str.format(), and the finished result has mark_safe() applied. That is exactly the split you want: your markup is trusted because you typed it, your data is escaped because you did not.
from django.utils.html import format_html
from django.utils.safestring import mark_safe
# Dangerous. label is interpolated unescaped.
def badge_bad(label):
return mark_safe(f'<span class="badge">{label}</span>')
# Correct. The markup is literal, label is escaped.
def badge_good(label):
return format_html('<span class="badge">{}</span>', label)
badge_good('<script>alert(1)</script>')
# '<span class="badge"><script>alert(1)</script></span>'The two functions are three characters apart in a code review and worlds apart in behavior. That is why teams that care about this ban bare mark_safe() with an f string or a percent format in a linter rule rather than relying on reviewers to spot it.
For a list, format_html_join() saves you a loop. It takes a separator, one format string, and a generator of argument tuples or mappings, and it escapes the separator too.
from django.utils.html import format_html_join
def user_items(users):
return format_html_join(
"\n",
"<li>{} {}</li>",
((u.first_name, u.last_name) for u in users),
)
def book_items(books):
return format_html_join(
"\n",
'<li data-id="{id}">{id} {title}</li>',
({"id": b.id, "title": b.title} for b in books),
)str.format() options do not work in format_html(), because every argument is passed through conditional_escape() first. Format the number in Python, then pass the finished string.mark_safe, and how safety gets lost
mark_safe() returns a SafeString, which is a subclass of str that the template engine recognizes as needing no further escaping. It can be called more than once on the same string and it can be used as a decorator. What it never does is inspect the content.
The subtle failure is that safety does not survive string operations. Any method that returns a new object hands you back a plain str, and the mark is gone.
>>> from django.utils.safestring import mark_safe
>>> s = mark_safe("<b>Hello World</b> ")
>>> type(s)
<class 'django.utils.safestring.SafeString'>
>>> type(s.strip()) # whitespace removed, safety removed with it
<class 'str'>So a helper that marks a fragment safe and then hands it to something that trims, joins, or truncates it will silently start escaping again. That is the benign version. The dangerous version is the reverse: a value that was escaped, then passed through a function that reassembles it and marks the result safe.
The worst pattern of all involves strip_tags(). Django’s documentation is emphatic that no guarantee is provided about the result being HTML safe, and that you must never mark safe the result of a strip_tags() call without escaping it first.
from django.utils.html import escape, strip_tags
from django.utils.safestring import mark_safe
mark_safe(strip_tags(user_input)) # never do this
escape(strip_tags(user_input)) # this is fine; escape applies mark_safe itselfIn templates: safe, safeseq and autoescape
The template side has three tools. The safe filter marks one value as needing no escaping. The safeseq filter applies safe to each element of a sequence, which is what you need before something like join, because safe on a list would stringify the whole list first. The autoescape block turns escaping on or off for a region.
{{ page.rendered_body|safe }}
{{ tag_links|safeseq|join:", " }}
{% autoescape off %}
{{ email_template_fragment }}
{% endautoescape %}Filter order is load bearing. Applying a filter after safe can make the value unsafe again, so {{ var|safe|escape }} ends up escaped. Treat {% autoescape off %} as a red flag in review: it covers everything inside the block including variables added to that block six months later by someone who did not read this far.
Admin and model methods
Admin columns are where most teams meet this API for the first time, because a plain string returned from a display method is escaped and shows up as literal angle brackets in the changelist.
from django.contrib import admin
from django.utils.html import format_html
@admin.register(Article)
class ArticleAdmin(admin.ModelAdmin):
list_display = ["title", "status_badge"]
@admin.display(description="Status")
def status_badge(self, obj):
color = "green" if obj.published else "gray"
return format_html(
'<span style="color: {}">{}</span>',
color,
obj.get_status_display(),
)Same rule as everywhere else. The markup is a literal in the format string, the two variables are arguments, and the return value is already a SafeString so the admin renders it as markup.
Troubleshooting
Angle brackets show up as text. The value is not marked safe. If you built it in Python, switch to format_html(). If it arrives from elsewhere and you trust it, use the safe filter at the point of rendering.
Output is double escaped, showing &lt;. Something escaped an already escaped string. Use conditional_escape() rather than escape() in helpers that might receive either kind, since it leaves preescaped strings alone.
A helper works in one template and escapes in another. Look for a string operation between the two call sites. Slicing, stripping or concatenating a SafeString with a plain str produces a plain str.
A custom filter escapes its output. Register it with @register.filter(is_safe=True) only if it genuinely cannot introduce unsafe characters, and use conditional_escape() on any input you interpolate.
Everything inside a block renders raw. There is an {% autoescape off %} above it, possibly in an included template or a parent block. Grep the whole template tree, not just the file you are looking at.
Frequently asked questions
Is mark_safe ever the right call?
Yes, for a string literal you wrote with no interpolation, such as a fixed icon fragment or a constant separator. The moment any variable enters the string, switch to format_html(). If you cannot point at every character in the output and say where it came from, do not mark it safe.
What is the difference between escape and conditional_escape?
Both encode ampersands, quotes and angle brackets and return a safe string. The difference is that conditional_escape() leaves already escaped strings alone, so it will not double escape. Use it inside helpers that may be handed either raw text or a SafeString.
Can I allow some HTML from users, like bold and links?
Not with these tools. mark_safe() is all or nothing and strip_tags() makes no safety guarantee. Allowing a subset of tags requires a dedicated sanitizer library that parses the markup and rebuilds it from an allowlist, then you render its output.
Does the safe filter work when autoescape is off?
It has no effect there. With autoescaping disabled for a block, nothing is being escaped in the first place, so marking a value safe changes nothing. That asymmetry is one more reason to leave autoescaping on and mark individual values instead.
Why does my number lose its formatting inside format_html?
Because arguments pass through conditional_escape(), which coerces them to strings before str.format() sees them, so format specifiers on those arguments do not apply. Build the formatted string first and pass the result in as a plain argument.
The bottom line
Default to format_html() and format_html_join() for anything you assemble in Python, and treat a bare mark_safe() around an f string as a bug rather than a style choice. That single habit removes the entire category of mistake, and it reads no worse than the dangerous version.
Everything else follows from knowing that SafeString is just a subclass of str with a flag, and that the flag disappears the moment anything creates a new string. Check the django.utils.html reference and the template builtins on autoescaping when you are unsure. If you work across template languages, our guide to unpacking multiple variables in a Jinja2 loop covers the same terrain in the other engine, and the escaping discipline generalizes to running database queries safely and to careful string comparison.
About this article: GeekBlog covers U.S. technology news, AI, phones, smartwatches and gaming. Every story is written and checked under our Editorial Policy. Spotted a mistake or have a story tip? Contact our editors.

