Internationalization (i18n)

A web application that runs in only one language is the same application that runs in only one country. Internationalization (i18n) is the work of making your application capable of supporting any locale - extracting the strings, leaning on locale-aware formatters, never assuming the user reads English or thinks in dollars. Localization (l10n) is the next step: providing the actual translations and locale-specific formats for the regions you want to ship to.

This guide covers what you need to take a Proper application from English-only to multilingual. It stops just short of the deeper API; for the full method reference, see the API docs.


1. Setup

The framework does not assume you want internationalization. Many applications don't need translation catalogs, and shipping them by default would be dead weight.

So Proper ships i18n as an addon you install on demand.

$ proper install i18n

Once installed, you get:

  • A single I18n instance at app.i18n, callable like a function.
  • A _() global available in every template.
  • A config/locales/ directory.
  • Two controller concerns that pick the right locale and timezone for each request automatically.
  • Two new dependencies in your pyproject.toml: babel and poyo (the YAML parser used for catalogs).

2. How Proper Picks a Locale per Request

Proper resolves the current locale through a controller concern, not a middleware. The advantage is that it sits inside the controller pipeline - it can read the session, look at the signed-in user, and decide based on the request parameters - and it follows the same before hook machinery as everything else.

2.1 The Resolution Order

CurrentLocale lives at proper.concerns.CurrentLocale and runs as a before hook on every action in your AppController.

# myapp/controllers/app_controller.py
from proper import Controller
from proper.concerns import CurrentLocale, CurrentTimezone


class AppController(Controller):
    concerns = [
        # ...
        CurrentLocale,
        CurrentTimezone,
    ]

It walks through five sources and picks the first one that returns a value, because there are several different ways an application might decide a user's language:

  1. URL parameter
    ?locale=fr in the query string or as a route parameter, so shareable links carry the language. Good for marketing pages and content sites.

  2. Cookie
    A locale cookie set on a previous request. The user picks once and Proper remembers. Good for general consumer apps where the user might want to switch languages.

  3. Signed-in user
    Signed-in users get their saved language on every device. Add a locale column to your User model and the concern picks it up automatically.

  4. Accept-Language header
    Negotiated against the locales you actually have catalogs for, via app.i18n.negotiate_locale(request.accept_language). Good as a first guess.

  5. LOCALE_DEFAULT
    The fallback value from config/main.py. This is the locale that format methods use when you haven't installed the addon.

The result is stored on current.locale for the rest of the request - controllers, templates, emails, anything that runs during that request sees the same value.

Pick whichever combination fits your application; the resolver does the rest.

Setting locale from the domain

Proper does not ship a "set locale from the domain name" pattern out of the box. If you serve example.com in English and example.es in Spanish, write a small concern that reads request.host and assigns current.locale before CurrentLocale runs (or replace CurrentLocale with your own).

2.2 Reading and Overriding

In any controller, view, email template, or any code that runs during the request, you read the current locale from the request-scoped current object:

from proper import current

current.locale     # e.g. "en", "es_PE", "pt_BR"
current.timezone   # a datetime.tzinfo instance

When you want to translate something in a different locale than the one Proper picked - rendering an email to a user whose preference differs from yours, for example - you pass locale=... to the translator directly:

{{ _("welcome", name=user.name, locale=user.locale) }}
# or `app.i18n("welcome", ...)` in python code.

That doesn't change current.locale; it only affects that one call.

2.3 Timezone Resolution

CurrentTimezone is the parallel concern for timezones. It uses the same shape and the same source order, minus the Accept-Language step (HTTP has no equivalent header for timezones):

  1. URL parameter - ?timezone=America/Lima.
  2. Cookie - a timezone cookie.
  3. Signed-in user - current.user.timezone.
  4. TIMEZONE_DEFAULT from config/main.py.

Values are IANA names like America/Lima, Europe/Berlin, or Asia/Tokyo - not abbreviations like EST or CET, which are ambiguous.

The current timezone is what every date/time formatter falls back to when you don't pass an explicit timezone= argument. Two users in different timezones, looking at the same order.created_at, will see two different wall-clock times:

{# current.timezone = "Europe/Berlin" -> "Mar 5, 2026, 9:47 PM" #}
{# current.timezone = "America/Lima"  -> "Mar 5, 2026, 3:47 PM" #}
{{ order.created_at | format_datetime }}

Locale and timezone are independent dimensions. A user in Lima may read your application in English; a user in Berlin may read it in Japanese. Proper treats them as two separate concerns deliberately, so you can mix and match.

When you only need one timezone

If your application is single-timezone - a regional service, an internal tool for one office - leave the concern in place and just set TIMEZONE_DEFAULT = "America/Lima" (or whichever IANA name applies). Every user gets the same timezone, no per-user storage needed.


3. Writing Translation Catalogs

A translation catalog is a YAML file. The top-level key is the locale code and everything underneath is the dictionary of translations for that locale:

config/locales/en.yml
en:
  hello: "Hello world"
  greetings:
    morning: "Good morning, {name}!"
    evening: "Good evening, {name}!"
  errors:
    not_found: "Page not found"

3.1 File Layout

Files live under config/locales/. Proper scans the directory recursively and reads every .yml and .yaml file it finds, so you can organize them however you want:

config/locales/
├── en.yml
├── es.yml
├── orders/
│   ├── en.yml
│   └── es.yml
└── emails/
    ├── en.yml
    └── es.yml

Each file contributes to the locale named at its top level. Two files both starting with en: are merged: en.yml defines the global strings, orders/en.yml adds order-specific ones, and app.i18n("orders.confirm") finds the second one without you doing anything.

When two files set the same key, the later one wins. The merge is deep, so orders.confirm in one file and orders.cancel in another both survive.

3.2 Locale Codes

Proper normalizes locale codes internally. en-US, en_us, and EN-US all become en_US. Pick one form for your filenames - the convention is lowercase language, underscore, uppercase territory: en_GB, pt_BR, es_PE - and stay consistent.

If a locale has no regional variant, just use the language code: en, es, fr, ja.

3.3 The YAML Boolean Gotcha

Quote your "yes" and "no"

YAML interprets unquoted true, false, on, off, yes, and no (case-insensitive) as booleans. If you write yes: "Sí", the key becomes Python's True and lookups for "yes" will fail. Always quote those keys:

en:
  "yes": "Yes"
  "no": "No"
  enabled: "ON"

This is a YAML pitfall, not a Proper one - but it bites everyone at least once, so the generated config/locales/README.md reminds you about it as well.


4. Looking Up Translations

4.1 In Controllers and Python Code

Inside any controller, model, email, or background task, you call the app.i18n instance directly:

app.i18n("greetings.morning", name="Susan")
# -> "Good morning, Susan!"

That's the short form. The full method name is translate:

app.i18n.translate("greetings.morning", name="Susan")

A few details worth knowing:

  • Keys use dot notation to walk nested dictionaries. "greetings.morning" looks up morning inside the greetings dict.
  • Every keyword argument is available as a placeholder in the translated string, using Python's str.format syntax: "{name}".
  • A missing key does not raise. It returns <missing:greetings.morning/> (HTML-safe) so the missing translation is visible in the rendered page without crashing your application. This is deliberate - a missed translation in production should degrade gracefully, not 500.

4.2 In Templates

The _() global is the same app.i18n instance, registered as a Jinja global by the addon. Use it the same way:

<h1>{{ _("greetings.morning", name=current.user.name) }}</h1>

Translation results are wrapped in Markup, so any HTML you put in the YAML is rendered, not escaped:

en:
  policy: "Read our <a href='/policy'>privacy policy</a>."
{{ _("policy") }}
{# Renders the <a> tag as HTML, not as text. #}

That's a feature when you need it and a footgun when you don't. Treat translation YAML the same way you treat any other file that ends up in your HTML: don't put user-supplied content in it.

4.3 Interpolating Values

All placeholders are named, never positional:

en:
  welcome: "Welcome, {name}! You have {count} new messages."
app.i18n("welcome", name="Alex", count=3)

count is special in one small way: it's always available as a placeholder, even when you're not using pluralization. If you pass count=3 to a non-pluralized key, {count} in the string will be filled with 3. If you pass no count, it defaults to 1.

4.4 Lazy Translation

Sometimes you need the key, not the translated string, at the time the code runs - and you only want the translation to happen later, when something is actually rendered. Form-field labels declared at class scope are the classic case: the class is defined once at import time, but current.locale only exists during a request.

app.i18n.lazy_translate returns a small wrapper whose __repr__ calls the translator at render time, picking up whatever current.locale is then:

t = app.i18n.lazy_translate

class CardForm(Form):
    title = f.TextField(label=t("forms.card.title"))
    body = f.TextField(label=t("forms.card.body"))

In practice, this is almost the only place beginners reach for lazy_translate. Everywhere else - inside actions, inside templates - the regular app.i18n(...) call already runs at the right moment.


5. Pluralization

Most languages need at least two forms - "1 apple" vs "5 apples" - and some need many more and have complex rules about it. Proper handles pluralization with CLDR rules via Babel, which means the rules are correct for every locale; you only need to provide the strings.

5.1 Basic Plurals

When the value at a key is a dictionary with CLDR category keys, Proper picks the right one based on count:

en:
  apples:
    one: "{count} apple"
    other: "{count} apples"
app.i18n("apples", count=1)   # "1 apple"
app.i18n("apples", count=5)   # "5 apples"

The default count is 1

If the value at a key is a dict with plural forms but you don't pass count, Proper defaults to count=1. So app.i18n("apples") returns "1 apple", not the other form. Pass count explicitly when it matters.

The categories Babel knows about are zero, one, two, few, many, and other. Which ones apply depends on the locale:

  • English needs only one and other.
  • Russian uses one, few, many, and other.
  • Arabic uses all six.

other is the catch-all and should always be present.

5.2 Exact-Count Overrides

You can short-circuit CLDR for specific numbers. This is handy for messages that read more naturally as fixed sentences for small counts:

en:
  inbox:
    0: "Your inbox is empty"
    1: "You have one message"
    other: "You have {count} messages"

The lookup order Proper follows for a pluralized value is:

  1. The integer key matching count exactly.
  2. The string key matching count exactly.
  3. The "zero" key, if count == 0.
  4. The CLDR plural form for the current locale (one, few, other, etc.).
  5. Otherwise, an empty string.

6. Locale Fallbacks

Once you start translating into more than two or three locales, you might want to have regional variants (like the different versions of spanish spoken in Peru, Argentina, Colombia, and Chile). However, you'll want to share strings between them. British English and American English overlap on 99% of strings; Brazilian and European Portuguese overlap on almost as much. Proper supports this with a territory → language fallback.

6.1 How the Fallback Works

When current.locale includes a territory - en_GB, pt_BR, es_PE - Proper looks up each key in two dictionaries, in this order:

  1. The territory-specific catalog (en_GB).
  2. The base language catalog (en).

The first place the key is found wins. This lets you keep most strings in the shared file and only override the words that actually differ:

config/locales/en.yml
en:
  colors:
    favourite: "What's your favorite color?"
  buttons:
    elevator: "Take the elevator"
  org: "organization"
  hello: "Hello world!"
config/locales/en_GB.yml
en_GB:
  colors:
    favourite: "What's your favourite colour?"
  buttons:
    elevator: "Take the lift"
  org: "organisation"

A request with current.locale = "en_GB" sees the British spellings; a request with current.locale = "en" sees the American ones; a request with current.locale = "en_AU" falls through en_AU (missing) → en and also sees the American ones, unless you add an en_AU.yml of its own.

So with the "en_GB" locale, _("button.elevator") will be "Take the lift", but _("hello") will fallback to the en translation "Hello world!", because the key is not in the locale-specific dict.

The same pattern works for pt_BR.yml falling back to pt.yml, es_PE.yml falling back to es.yml, and so on.

6.2 When the Locale Isn't Installed

If current.locale is set to a locale you have no catalog for - say someone manually visits ?locale=ja and you haven't written ja.yml - Proper raises TranslationsNotFound the first time you try to translate.

That sounds harsh, but it's deliberate: a half-translated production page is worse than a clear error in development. You avoid the error two ways:

  • Let the resolver do its job. The CurrentLocale concern only sets a locale that negotiate_locale() accepts. A browser asking for ja with no ja.yml installed falls through to the next source (cookie, user, default) automatically.
  • Don't pass installed-locale assumptions to explicit calls. app.i18n("welcome", locale="ja") will raise if you haven't installed ja. Pass only locales that are actually present in app.i18n.translations.

This is the opposite of Rails

Rails silently falls back to default_locale when the requested one is missing. Proper raises, on the theory that a missing-language error in dev is cheaper than a half-translated page in production.


7. Going Further: Locale-Aware Formatters

Translation is only half the job. Dates, numbers, currencies, and even list separators look different from one locale to another - "Mar 5, 2026" vs "5. März 2026", "$1,234.50" vs "1.234,50 €", "a, b, and c" vs "a, b und c". The i18n addon registers a full set of formatters on app.i18n, each available both as a Python method and as a Jinja filter:

Method Use it for
format_date, format_time, format_datetime Locale-aware date and time strings
format_timedelta Human-readable durations ("3 hours ago")
format_decimal, format_percent, format_scientific Numbers with the right thousands separator and decimal mark
format_currency, format_compact_currency Money in any ISO currency code ("USD", "EUR", "PEN")
format_list Locale-aware joined lists ("a, b, and c" in English, "a, b und c" in German)
format_size Human file sizes ("127 MB")

From a controller or model:

app.i18n.format_currency(1234.5, "EUR")
# "€1,234.50" when current.locale == "en"
# "1.234,50 €" when current.locale == "de"

From a template, the same operation is a one-call filter:

{{ 1234.5 | format_currency("EUR") }}

{{ user.last_login | format_timedelta(add_direction=True) }}
{# "3 hours ago" in English, "hace 3 horas" in Spanish #}

{{ attachment.byte_size | format_size }}
{# "127 MB" #}

Every formatter reads current.locale (and, for the time-related ones, current.timezone) automatically. You can override either with an explicit locale= or timezone= keyword argument when you need to.

This guide doesn't cover every option on every formatter - precision, format patterns, calendar names, numbering systems. See the API reference for the full signatures.