Custom Filters
When built-in filters aren’t enough, you can create custom ones.
Register a custom filter
Section titled “Register a custom filter”from flask import Flask
app = Flask(__name__)
def format_username(value: str) -> str:
return value.strip().title()
app.jinja_env.filters["format_username"] = format_usernameUse it in templates:
<p>{{ username | format_username }}</p>Alternative: decorator style
Section titled “Alternative: decorator style”Flask also supports:
@app.template_filter("format_username")
def format_username(value):
return value.strip().title()When to use custom filters
Section titled “When to use custom filters”- formatting dates
- truncating text in a consistent way
- normalizing usernames/display names
If the same template logic repeats, it’s a good candidate for a filter.
A filter is a function with a name in the template namespace
Section titled “A filter is a function with a name in the template namespace”@app.template_filter("shout")
def shout(s):
return str(s).upper() + "!"{{ 'hi'|shout }} -> HI!The value on the left of | becomes the first argument. Extra arguments come from the
call: {{ x|round(2) }} invokes round(x, 2). Filters chain left to right, so
{{ v|upper|replace('A','@') }} uppercases first and then replaces — measured '@BC'
for v = 'abc'.
flowchart LR
V["value"] --> F1["|upper"] --> F2["|replace('A','@')"] --> O["output, then escaped"]
The part that goes wrong: filters that return HTML
Section titled “The part that goes wrong: filters that return HTML”A filter’s return value is escaped like any other value. A filter that builds markup therefore comes out as visible text:
@app.template_filter("badge_unsafe")
def badge_unsafe(s):
return f"<span class='b'>{s}</span>" # a plain str{{ 'ok'|badge_unsafe }} -> <span class='b'>ok</span>The obvious fix is |safe, and it is the wrong one:
{{ v|badge_unsafe|safe }} where v = <script>x</script>
-> <span class='b'><script>x</script></span> <- the script runs|safe disables escaping for the whole result, including the interpolated user
value. The correct approach marks only the wrapper as safe and lets the value be
escaped:
from markupsafe import Markup
@app.template_filter("badge_safe")
def badge_safe(s):
return Markup("<span class='b'>{}</span>").format(s){{ v|badge_safe }}
-> <span class='b'><script>x</script></span> <- markup kept, input escapedMarkup.format escapes every argument as it substitutes. That single difference is what
separates a safe HTML-producing filter from an injection point.
Built-ins worth knowing before writing your own
Section titled “Built-ins worth knowing before writing your own”| filter | measured |
|---|---|
{{ v|default('none') }} when v is missing | none |
{{ [1,2,3]|length }} | 3 |
{{ ['a','b']|join('-') }} | a-b |
{{ 3.14159|round(2) }} | 3.14 |
{{ {'a':1}|tojson }} | {"a": 1} |
tojson is the safe way to hand data to JavaScript — it escapes characters that would
otherwise break out of a <script> block.
See it move
Section titled “See it move”Check yourself
Section titled “Check yourself”-
A filter returns the plain string '<span>ok</span>'. What appears on the page?
A filter's return value is escaped like any other value. To emit real markup the filter must return Markup, not a str.
pch.quizShowAnswer
B — the escaped text <span>ok</span>, visible to the reader — A filter's return value is escaped like any other value. To emit real markup the filter must return Markup, not a str.
-
Why is {{ v|badge_unsafe|safe }} dangerous when v comes from a request?
Measured with v = <script>x</script>: the output was <span class='b'><script>x</script></span>. Return Markup(...).format(s) instead so the wrapper is markup and the value is escaped.
pch.quizShowAnswer
B — |safe disables escaping for the whole result, including the interpolated user value, so a script tag in v executes — Measured with v = <script>x</script>: the output was <span class='b'><script>x</script></span>. Return Markup(...).format(s) instead so the wrapper is markup and the value is escaped.
-
What does Markup('<span>{}</span>').format(s) do differently from an f-string?
Markup.format escapes its arguments as it substitutes them. That is what makes the wrapper render as markup while hostile input is neutralised.
pch.quizShowAnswer
B — it escapes each substituted argument while keeping the surrounding markup as HTML — Markup.format escapes its arguments as it substitutes them. That is what makes the wrapper render as markup while hostile input is neutralised.
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading