Rustango docs
← Guides

Internationalization (i18n)

Internationalization is serving your app in the user's language. Rustango splits it into two halves: resolving which locale a request wants (cookie → Accept-Language → default — covered in Middleware), and translating strings into that locale, which is this guide. The Translator loads message catalogs per locale, substitutes {placeholders}, handles plurals, and falls back gracefully — Django's gettext / {% trans %}, in Rust.

i18n in rustango: a Translator holds per-locale catalogs; gettext looks up a key with fallback (locale → base language → default → the key itself), substitutes {name} placeholders, and ngettext picks singular vs plural

New to a term here? locale, catalog, Accept-Language, pluralization, RTL — see the glossary.

Source: rustango::i18n (Translator, Locale, negotiate_language, is_rtl_language, language_native_name) — always compiled. The per-request locale/timezone middleware lives in rustango::i18n::middleware / ::timezone (see Middleware).

Runnable version: every snippet is copied from i18n_doc.rs (cargo test -p rustango --test i18n_doc); DB-backed translation overrides are dogfooded by i18n_db_overrides_sqlite_live.rs.

Table of contents


The two halves of i18n

ConcernWhereWhat it does
Resolve the localei18n::middleware::LocaleMiddlewarepicks a locale per request (cookie → Accept-Language → default) and exposes the ActiveLocale extractor
Translate stringsi18n::Translator (this guide)looks up a message key in the active locale's catalog
Render dates in the user's TZi18n::timezonethe {{ ts | localtime }} filter

You wire the middleware once, then translate using the locale it resolved.


Step 1 — Build a Translator

A Translator holds one catalog (a key → message map) per locale. Build it with a default locale, then add catalogs:

use rustango::i18n::{Locale, Translator};
use std::collections::HashMap;

let mut en = HashMap::new();
en.insert("greeting".to_owned(), "Hello".to_owned());
en.insert("welcome".to_owned(), "Welcome, {name}!".to_owned());

let mut fr = HashMap::new();
fr.insert("greeting".to_owned(), "Bonjour".to_owned());

let translator = Translator::new(Locale::new("en"))   // default locale
    .add_locale(Locale::new("en"), en)
    .add_locale(Locale::new("fr"), fr);

Keep the Translator in your app state (it's cheap to clone-share) and look up strings with the ActiveLocale the middleware resolved.


Step 2 — Translate strings (gettext)

gettext(locale, key) returns the message for that locale — and degrades safely when something's missing:

translator.gettext("en", "greeting");      // "Hello"
translator.gettext("fr", "greeting");      // "Bonjour"

translator.gettext("en", "missing.key");   // "missing.key"  — returns the key, never panics
translator.gettext("de", "greeting");      // "Hello"        — unknown locale → default

A missing key returns the key itself, so an untranslated string shows up visibly rather than crashing the page.


Placeholders

Messages can carry {name} placeholders; translate substitutes them from key/value pairs:

// catalog: "welcome" = "Welcome, {name}!"
translator.translate("en", "welcome", &[("name", "Ada")]);   // "Welcome, Ada!"

gettext is the no-placeholder shorthand; translate (and gettext_fmt) take params.


Pluralization

Plurals differ by language and count. ngettext picks the singular key when count == 1 and the plural key otherwise, binding {count} automatically:

// catalog: "cart.one" = "1 item",  "cart.other" = "{count} items"
translator.ngettext("en", "cart.one", "cart.other", 1);   // "1 item"
translator.ngettext("en", "cart.one", "cart.other", 5);   // "5 items"

Use ngettext_fmt to pass extra placeholders alongside {count}.


Fallback order

Lookups walk a chain so a partial translation never leaves a blank: the requested locale → its base language → the fallback chain → the default locale → the key itself. So a French-Canadian request resolves against the fr catalog:

// only "fr" is registered, not "fr-CA"
translator.gettext("fr-CA", "greeting");   // "Bonjour"  — base-language fallback

Set extra fallbacks with Translator::new(default).with_fallback_chain(&["en"]).


Negotiating the language

If you're resolving the locale yourself (outside the middleware), negotiate_language parses a browser Accept-Language header and picks the best match from the locales you support:

use rustango::i18n::negotiate_language;

negotiate_language("fr-FR,fr;q=0.9,en;q=0.8", &["en", "fr"]);   // Some("fr")
negotiate_language("de,ja;q=0.5", &["en", "fr"]);               // None — fall back to default

This is exactly what LocaleMiddleware uses under the hood.


Right-to-left languages

is_rtl_language (and Locale::direction()) tell you whether to set dir="rtl" on the page — for Arabic, Hebrew, Persian, etc.:

use rustango::i18n::is_rtl_language;

is_rtl_language("ar");   // true
is_rtl_language("en");   // false

In a template: <html dir="{{ direction }}">, fed from the ActiveLocale. See the RTL note in Middleware.


Loading catalogs + runtime overrides

You rarely hand-build maps in production. Two loaders:

  • Translator::from_directory(dir, default) — load one catalog file per locale from a directory at startup.
  • Translator::from_settings(&settings.i18n) — wire it from your config.

And operators can edit translations at runtime without a redeploy: set_override(locale, key, value) (or load_overrides(rows) from a table) layer on top of the file catalogs and win for that key. This DB-override flow powers the admin translation editor and is dogfooded in i18n_db_overrides_sqlite_live.rs.


See also

  • Middleware — resolving the per-request locale (LocaleMiddleware, ActiveLocale) and the {{ ts | localtime }} timezone filter.
  • HTML views · The admin — where translated strings and the translation editor surface.
  • Glossary — locale, catalog, RTL, and friends in plain language.