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
I18ninstance atapp.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:babelandpoyo(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:
URL parameter
?locale=frin the query string or as a route parameter, so shareable links carry the language. Good for marketing pages and content sites.Cookie
Alocalecookie 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.Signed-in user
Signed-in users get their saved language on every device. Add alocalecolumn to yourUsermodel and the concern picks it up automatically.Accept-Languageheader
Negotiated against the locales you actually have catalogs for, viaapp.i18n.negotiate_locale(request.accept_language). Good as a first guess.LOCALE_DEFAULT
The fallback value fromconfig/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):
- URL parameter -
?timezone=America/Lima. - Cookie - a
timezonecookie. - Signed-in user -
current.user.timezone. TIMEZONE_DEFAULTfromconfig/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:
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 upmorninginside thegreetingsdict. - Every keyword argument is available as a placeholder in the translated string, using Python's
str.formatsyntax:"{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
oneandother. - Russian uses
one,few,many, andother. - 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:
- The integer key matching
countexactly. - The string key matching
countexactly. - The
"zero"key, ifcount == 0. - The CLDR plural form for the current locale (
one,few,other, etc.). - 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:
- The territory-specific catalog (
en_GB). - 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:
en:
colors:
favourite: "What's your favorite color?"
buttons:
elevator: "Take the elevator"
org: "organization"
hello: "Hello world!"
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
CurrentLocaleconcern only sets a locale thatnegotiate_locale()accepts. A browser asking forjawith noja.ymlinstalled 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 installedja. Pass only locales that are actually present inapp.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.