Internationalization (i18n)

Translate templates with trans blocks, pluralization, and gettext integration

4 min read 835 words

Kida has built-in support for translatable templates. Mark strings with {% trans %}, wire up a gettext backend, and Kida handles escaping, pluralization, and variable interpolation.

Quick start

{% trans %}Hello, world!{% endtrans %}

Without translations installed, strings pass through unchanged. Install a gettext translations object to translate them:

import gettext

translations = gettext.translation("myapp", "locales", languages=["fr"])

env = Environment(loader=loader)
env.install_translations(translations)

Now {% trans %}Hello, world!{% endtrans %} renders "Bonjour, monde !"(or whatever the French.pofile says).

Syntax

Simple translation

{% trans %}Hello, world!{% endtrans %}

Message ID: "Hello, world!"

With variables

Declare variable bindings in the{% trans %}tag. Reference them as {{ name }}in the body:

{% trans name=user.name %}Hello, {{ name }}!{% endtrans %}

Message ID: "Hello, %(name)s!"

The translated string uses Python%-formatting. Variable values are HTML-escaped automatically (see Escaping below).

Pluralization

Use{% plural %} to provide singular and plural forms. A countvariable is required -- it determines which formngettextselects:

{% trans count=items | length %}
  One item found.
{% plural %}
  {{ count }} items found.
{% endtrans %}

Singular ID: "One item found." Plural ID:"%(count)s items found."

Whencountis 1, the singular form renders. Otherwise, the plural form renders withcountinterpolated.

Multiple variables

Separate bindings with commas:

{% trans name=user.name, count=user.posts | length %}
  {{ name }} has one post.
{% plural %}
  {{ name }} has {{ count }} posts.
{% endtrans %}

Closing tags

Both{% endtrans %} and {% end %}work:

{% trans %}Hello{% endtrans %}
{% trans %}Hello{% end %}

Shorthand functions

For inline translations, use the_ and _nglobals:

{{ _("Hello") }}
{{ _n("%(count)s item", "%(count)s items", count) }}

These are regular functions available in any expression context -- output tags, filter arguments, default values, etc.:

{% def greeting(msg=_("Hello")) %}
  <h1>{{ msg }}</h1>
{% end %}

Whitespace handling

Whitespace inside{% trans %}bodies is normalized. Leading and trailing whitespace is stripped, and internal whitespace is collapsed to single spaces:

{% trans %}
  Hello,
  world!
{% endtrans %}

Message ID: "Hello, world!" (not "\n Hello,\n world!\n")

This keeps message IDs clean regardless of template indentation.

Escaping

Translation and escaping interact carefully:

Scenario Behavior
No variables Translated string is escaped via the autoescape function
With variables String is wrapped inMarkup, variable values are auto-escaped
Markupvalues Not double-escaped --Markupinstances pass through

Variable escaping

{% trans name=user_input %}Hello, {{ name }}!{% endtrans %}

If user_input is "<script>alert(1)</script>", the output is:

Hello, &lt;script&gt;alert(1)&lt;/script&gt;!

The translated string itself is trusted (translators produce safe content). Variable values are escaped on interpolation viaMarkup.__mod__.

Pre-escaped values

If a variable is alreadyMarkup, it is not double-escaped:

from kida.utils.html import Markup

template.render(safe_html=Markup("<b>bold</b>"))
{% trans name=safe_html %}Hello, {{ name }}!{% endtrans %}
{# Hello, <b>bold</b>! #}

Body restrictions

Inside{% trans %} bodies, only simple {{ name }}references are allowed. Filters, attribute access, and method calls are not permitted in the body:

{# Good -- bind the expression in the tag #}
{% trans name=user.name | title %}Hello, {{ name }}!{% endtrans %}

{# Bad -- complex expression in body #}
{% trans %}Hello, {{ user.name | title }}!{% endtrans %}

This constraint ensures message IDs are predictable and extractable. Bind complex expressions in the{% trans %}tag, then reference the bound name in the body.

Configuration

install_translations

Pass a standard librarygettext.GNUTranslationsobject (or any object with gettext() and ngettext()methods):

import gettext

translations = gettext.translation("myapp", "locales", languages=["fr"])
env.install_translations(translations)

install_gettext_callables

Pass the translation functions directly:

env.install_gettext_callables(
    gettext=my_gettext_func,
    ngettext=my_ngettext_func,
)

Default behavior

When no translations are installed, Kida uses identity functions:

  • _("Hello") returns "Hello"
  • _n("item", "items", 1) returns "item"
  • _n("item", "items", 5) returns "items"

Templates render untranslated strings. No configuration needed for single-language projects.

Patterns

Per-request language switching

In web applications, install translations per request:

def get_translations(locale: str):
    return gettext.translation("myapp", "locales", languages=[locale])

@app.before_request
def set_locale():
    locale = request.accept_languages.best_match(["en", "fr", "de"])
    app.jinja_env.install_translations(get_translations(locale))

Translation in components

{% trans %} works inside {% def %}components:

{% def nav_link(href, active=false) %}
<a href="{{ href }}" {% if active %}class="active"{% end %}>
  {% slot %}{% end %}
</a>
{% end %}

{% call nav_link("/settings") %}
  {% trans %}Settings{% endtrans %}
{% end %}

Pluralized counts with formatting

{% trans count=notifications | length %}
  You have one new notification.
{% plural %}
  You have {{ count }} new notifications.
{% endtrans %}

Static content with no variables

For simple strings,{% trans %}requires no bindings:

<footer>
  {% trans %}Copyright 2026. All rights reserved.{% endtrans %}
</footer>