Skip to content

Custom Template Tags & Filters

Buraq lets you register custom globals, filters, and tests for Jinja2 templates using simple decorators — no tag library files, no {% load %}, no boilerplate.


Step 1 — create templatetags.py in your app:

myapp/templatetags.py
from buraq.template import register
@register.global
def format_price(amount):
return f"${amount:,.2f}"

Step 2 — use it anywhere, no imports needed:

any template — just works
{{ format_price(product.price) }}
{{ format_price(product.price, "$") }}

That’s it. Buraq auto-discovers the file at startup and registers everything globally.

Why Buraq wins:

  • Simpler — 1 file, 1 decorator, no {% load %} ever vs 5 steps (directory, __init__.py, tag file, Library(), {% load %} in every template)
  • Safer — registration errors are caught at startup, not silently at render time when a template forgets {% load %}
  • Faster — no {% load %} parsing on every request; all tags registered once into env.globals at startup
  • More powerful@register.global exposes a full Python callable with natural argument and keyword-argument support ({{ fn(a, b, key=val) }}); simple_tag cannot be called with keyword args as naturally in template syntax

Create a templatetags.py file inside any app listed in INSTALLED_APPS:

myapp/
├── models.py
├── views.py
├── urls.py
└── templatetags.py ← create this

Buraq auto-discovers and imports every templatetags.py at startup. Everything registered inside is available in all templates immediately.


A global is a callable available anywhere in a template — called like a function.

myapp/templatetags.py
from buraq.template import register
@register.global
def format_price(amount, currency="$"):
return f"{currency}{amount:,.2f}"
@register.global
def now():
from buraq.utils.timezone import now as _now
return _now()
template.html
<span>{{ format_price(product.price) }}</span>
<span>{{ format_price(product.price, currency="") }}</span>
<footer>Generated at {{ now() }}</footer>

A filter transforms a value via the | pipe syntax.

myapp/templatetags.py
from buraq.template import register
@register.filter
def truncate(value, length=100):
value = str(value)
return value[:length] + "" if len(value) > length else value
@register.filter
def currency(amount, symbol="$"):
return f"{symbol}{float(amount):,.2f}"
@register.filter
def initials(name):
return "".join(w[0].upper() for w in name.split() if w)
template.html
{{ product.description|truncate(150) }}
{{ order.total|currency("£") }}
{{ user.full_name|initials }}

A test is used in {% if x is mytest %} conditions.

myapp/templatetags.py
from buraq.template import register
@register.test
def even(value):
return int(value) % 2 == 0
@register.test
def published(post):
return post.published and post.published_at is not None
template.html
{% if loop.index is even %}
<tr class="alt-row">
{% endif %}
{% if article is published %}
<span class="badge">Live</span>
{% endif %}

By default Jinja2 auto-escapes output. If your function returns trusted HTML, mark it safe with is_safe=True:

from buraq.template import register
@register.global(is_safe=True)
def icon(name):
return f'<svg class="icon icon-{name}"><use href="#{name}"/></svg>'
@register.filter(is_safe=True)
def highlight(value, term):
return str(value).replace(term, f"<mark>{term}</mark>")
{{ icon("search") }} {# rendered as raw HTML, not escaped #}
{{ article.body|highlight(query) }}

Use name= to register the function under a different name in templates:

from buraq.template import register
@register.global(name="static")
def static_url(path):
from buraq.conf import settings
return f"{settings.STATIC_URL}{path}"
@register.filter(name="nl2br", is_safe=True)
def newline_to_br(value):
return str(value).replace("\n", "<br>")
<img src="{{ static('images/logo.png') }}">
{{ post.body|nl2br }}

Globals and filters are plain Python functions — pass request explicitly from the view if needed, or read context from contextvars:

@register.global
def active_language():
from buraq.utils.translation import get_language
return get_language() # reads from LocaleMiddleware's ContextVar

Decorator Template usage Description
@register.global {{ fn(args) }} Callable available everywhere
@register.filter {{ value|fn(args) }} Transforms a value
@register.test {% if x is fn %} Boolean check

All decorators accept:

Option Type Description
name str Override the name used in templates
is_safe bool Wrap output in Markup to skip auto-escaping

Buraq registers 37 filters and 10 globals automatically — no {% load %} needed. They live in buraq/template/builtins.py and are installed via register_builtins(env) at startup. Jinja’s own 54 filters are there too, so lower, join, length, slice and the rest work under the names you expect.

Called, not written as tags — Jinja has no tag-registration syntax.

Global Usage
url(name, **params) {{ url("post-detail", pk=post.id) }}
static(path) {{ static("css/site.css") }} — hashed when manifest storage is on
csrf_token(request) / csrf_input(request) the token, and a ready-made hidden input
now(fmt) {{ now("Y-m-d") }}
firstof(*values, default="") {{ firstof(user.nickname, user.name, default="Anonymous") }}
widthratio(value, max, width) {{ widthratio(votes, total, 100) }} — a bar’s width
querystring(request, **changes) {{ querystring(request, page=2) }} — keeps the current filters
cycle(*values) alternating row classes
regroup(items, key) {% set groups = regroup(people, "city") %}
ifchanged(value) true only when the value differs from last time
spaceless(html) strips whitespace between tags
get_messages(request) the flash messages for this request

firstof earns its place over Jinja’s |default, which only catches an undefined name — not the empty string a blank form field or missing column actually is. querystring keeps the filters and page a visitor already has while changing one of them, which is otherwise a rebuild of the whole string by hand:

<a href="{{ querystring(request, page=page.next) }}">Next</a>

Pass None to drop a parameter, or a list to repeat one.

Filter Usage
date {{ dt|date("Y-m-d") }} — full Django format-code set
time {{ dt|time("H:i:s") }}
timesince {{ dt|timesince }}"2 hours, 5 minutes"
timeuntil {{ dt|timeuntil }}"3 days"
Filter Usage
truncatechars(n) {{ text|truncatechars(50) }}
truncatewords(n) {{ text|truncatewords(10) }}
wordcount {{ body|wordcount }}
capfirst {{ name|capfirst }}
addslashes {{ val|addslashes }}
slugify {{ title|slugify }}
linenumbers {{ code|linenumbers }}
pluralize {{ n|pluralize }} or {{ n|pluralize("y,ies") }}
yesno {{ flag|yesno("yes,no,maybe") }}
default_if_none {{ val|default_if_none("—") }}
phone2numeric {{ "HELLO"|phone2numeric }}"43556"
floatformat(n) {{ 3.14159|floatformat(2) }}"3.14"
stringformat(spec) {{ 5|stringformat("03d") }}"005" — Python’s % syntax without the %
add(n) {{ 4|add(6) }}10; joins lists and strings when they are not numbers
divisibleby(n) {{ 9|divisibleby(3) }}True
get_digit(n) {{ 123456789|get_digit(2) }}8 — counted from the right
escapeseq {{ items|escapeseq|join(", ") }} — escapes each item, not the joined string
safeseq {{ items|safeseq|join(", ") }} — marks each item safe

HTML (marked safe — output not auto-escaped)

Section titled “HTML (marked safe — output not auto-escaped)”
Filter Usage
linebreaks {{ body|linebreaks }}
linebreaksbr {{ body|linebreaksbr }}
urlize {{ text|urlize }}
escapejs var x = "{{ val|escapejs }}"
json_script(id) {{ data|json_script("app-data") }}
filesizeformat {{ size|filesizeformat }}"1.2 MB"