Skip to content

Custom Filters

When built-in filters aren’t enough, you can create custom ones.

python
from flask import Flask
 
app = Flask(__name__)
 
 
def format_username(value: str) -> str:
    return value.strip().title()
 
 
app.jinja_env.filters["format_username"] = format_username

Use it in templates:

html
<p>{{ username | format_username }}</p>

Flask also supports:

python
@app.template_filter("format_username")
def format_username(value):
    return value.strip().title()
  • 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”
filters.py
@app.template_filter("shout")
def shout(s):
    return str(s).upper() + "!"
usage
{{ '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'.

diagram Diagram mermaid

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:

badge.py
@app.template_filter("badge_unsafe")
def badge_unsafe(s):
    return f"<span class='b'>{s}</span>"        # a plain str
measured
{{ 'ok'|badge_unsafe }}       ->  &lt;span class=&#39;b&#39;&gt;ok&lt;/span&gt;

The obvious fix is |safe, and it is the wrong one:

measured, with hostile input
{{ 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:

badge_safe.py
from markupsafe import Markup
 
@app.template_filter("badge_safe")
def badge_safe(s):
    return Markup("<span class='b'>{}</span>").format(s)
measured, same hostile input
{{ v|badge_safe }}
->  <span class='b'>&lt;script&gt;x&lt;/script&gt;</span>     <- markup kept, input escaped

Markup.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”
filtermeasured
{{ v|default('none') }} when v is missingnone
{{ [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.

sketch A filter's output is escaped too p5.js
Returning a plain string gets escaped; adding |safe unescapes the user input as well; returning Markup escapes only the value.
pch.quizTag pch.quizDefaultTitle
  1. A filter returns the plain string '<span>ok</span>'. What appears on the page?

    pch.quizShowAnswer

    B — the escaped text &lt;span&gt;ok&lt;/span&gt;, 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.

  2. Why is {{ v|badge_unsafe|safe }} dangerous when v comes from a request?

    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.

  3. What does Markup('<span>{}</span>').format(s) do differently from an f-string?

    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.coffeeCta

pch.feedbackHeading

pch.feedbackSubheading