SEO

Rust i18n: Internationalization for Rust Applications

Eray Gündoğmuş
Eray Gündoğmuş
·13 min read
Share
Rust i18n: Internationalization for Rust Applications

Rust i18n: Internationalization for Rust Applications

Rust's systems programming roots might suggest it's primarily used for low-level, locale-agnostic code—but the reality is quite different. Rust powers web services, desktop applications, WebAssembly modules, and CLIs that need to serve global users in multiple languages.

The Rust i18n ecosystem is younger than many other languages but has seen significant investment, particularly around Mozilla's Fluent project and Unicode's ICU4X initiative. This guide covers the major libraries, patterns, and trade-offs for building internationalized Rust applications.

The Rust i18n Ecosystem

CratePurpose
fluent / fluent-bundleMozilla's Fluent localization system
fluent-templatesFluent integration for template engines
i18n-embedCompile-time locale embedding and runtime loading
rust-i18nMacro-based i18n with JSON/YAML/TOML support
icu (ICU4X)Unicode CLDR-based number, date, plural formatting
unic-langidBCP 47 language identifier parsing
accept-languageHTTP Accept-Language header parsing
chronoDate/time handling (locale-agnostic)

Approach 1: Fluent with fluent-bundle

Mozilla's Fluent is a localization system designed to address the limitations of gettext and key-value string maps. Fluent moves linguistic complexity out of your code and into the translation files, where translators can handle it.

Why Fluent?

Traditional approaches (t("key")) put linguistic decisions in the developer's hands: "do I need {count, plural, one {item} other {items}}?" Fluent moves these decisions to the translator: the translator writes the correct plural forms for their language.

Fluent also supports:

  • Attribute messages (a single entity with multiple translated variants)
  • Message references (reuse translated text in other messages)
  • Terms (shared, non-localizable definitions like brand names)
  • Selectors (conditionals based on variables, numbers, dates)

Fluent Syntax Example

# en-US/main.ftl

# Simple message
welcome = Welcome to our app!

# Message with a variable
greeting = Hello, { $name }!

# Message with plural selection
emails =
    { $count ->
        [0]    You have no new emails.
        [one]  You have one new email.
       *[other] You have { $count } new emails.
    }

# Message with attributes (for UI elements with label + tooltip)
submit-button =
    .label = Submit
    .tooltip = Click to submit your form

# Terms (brand names, non-translatable)
-brand-name = Acme Corp

referral = Thank you for using { -brand-name }!
# fr/main.ftl

welcome = Bienvenue dans notre application !

greeting = Bonjour, { $name } !

emails =
    { $count ->
        [0]    Vous n'avez pas de nouveaux e-mails.
        [one]  Vous avez un nouvel e-mail.
       *[other] Vous avez { $count } nouveaux e-mails.
    }

submit-button =
    .label = Soumettre
    .tooltip = Cliquez pour soumettre votre formulaire

referral = Merci d'utiliser { -brand-name } !

Using fluent-bundle

use fluent::{FluentBundle, FluentResource};
use fluent_langneg::negotiate_languages;
use unic_langid::LanguageIdentifier;

fn create_bundle(locale: &str, ftl_string: &str) -> FluentBundle<FluentResource> {
    let langid: LanguageIdentifier = locale.parse().expect("Invalid locale");
    let mut bundle = FluentBundle::new(vec![langid]);

    let resource = FluentResource::try_new(ftl_string.to_string())
        .expect("Failed to parse FTL");
    
    bundle.add_resource(resource).expect("Failed to add resource");
    bundle
}

fn translate_emails(bundle: &FluentBundle<FluentResource>, count: i64) -> String {
    let msg = bundle.get_message("emails").expect("Message not found");
    let pattern = msg.value().expect("Message has no value");
    
    let mut args = fluent::FluentArgs::new();
    args.set("count", count);
    
    let mut errors = vec![];
    let value = bundle.format_pattern(pattern, Some(&args), &mut errors);
    
    if !errors.is_empty() {
        eprintln!("Fluent errors: {:?}", errors);
    }
    
    value.into_owned()
}

fn main() {
    let en_ftl = include_str!("../locales/en-US/main.ftl");
    let bundle = create_bundle("en-US", en_ftl);
    
    println!("{}", translate_emails(&bundle, 0));  // "You have no new emails."
    println!("{}", translate_emails(&bundle, 1));  // "You have one new email."
    println!("{}", translate_emails(&bundle, 5));  // "You have 5 new emails."
}

Approach 2: rust-i18n (Macro-Based)

For simpler applications or developers who prefer a familiar t!() macro approach, rust-i18n provides an ergonomic interface backed by YAML translation files.

Setup

# Cargo.toml
[dependencies]
rust-i18n = "3"

[package.metadata.i18n]
available-locales = ["en", "fr", "de", "ja"]
default-locale = "en"
load-path = "locales"

Translation Files

# locales/en.yaml
welcome: "Welcome!"
greeting: "Hello, %{name}!"
items:
  zero: "No items"
  one: "One item"
  other: "%{count} items"
# locales/fr.yaml
welcome: "Bienvenue !"
greeting: "Bonjour, %{name} !"
items:
  zero: "Aucun élément"
  one: "Un élément"
  other: "%{count} éléments"

Using the t! Macro

use rust_i18n::t;

// Initialize i18n (loads locale files at compile time)
rust_i18n::i18n!("locales");

fn main() {
    // Set the active locale
    rust_i18n::set_locale("fr");
    
    // Simple translation
    println!("{}", t!("welcome")); // "Bienvenue !"
    
    // With variable interpolation
    println!("{}", t!("greeting", name = "Alice")); // "Bonjour, Alice !"
    
    // Plural handling
    for count in [0, 1, 5] {
        println!("{}", t!("items", count = count)); 
    }
}

Using Locale in a Web Handler (Axum)

use axum::{
    extract::{Extension, TypedHeader},
    headers::AcceptLanguage,
    response::Json,
};
use rust_i18n::t;
use serde_json::json;

async fn greet_handler(
    TypedHeader(accept_language): TypedHeader<AcceptLanguage>,
    Extension(state): Extension<AppState>,
) -> Json<serde_json::Value> {
    // Parse Accept-Language and select best locale
    let locale = negotiate_locale(&accept_language, &["en", "fr", "de"]);
    
    // Set locale for this request (note: this is thread-local in rust-i18n)
    rust_i18n::set_locale(&locale);
    
    Json(json!({
        "message": t!("welcome"),
        "locale": locale,
    }))
}

fn negotiate_locale(accept: &AcceptLanguage, supported: &[&str]) -> String {
    for quality_value in accept.iter() {
        let lang = quality_value.item.to_string();
        let prefix = lang.split('-').next().unwrap_or(&lang);
        if supported.contains(&prefix) {
            return prefix.to_string();
        }
    }
    "en".to_string()
}

ICU4X for Locale-Aware Formatting

ICU4X is a Unicode project providing CLDR-based internationalization primitives in Rust. It handles number formatting, date/time formatting, plural rules, and more—using Unicode's Common Locale Data Repository (CLDR) as its data source.

Number Formatting

use icu::decimal::FixedDecimalFormatter;
use icu::locid::locale;
use fixed_decimal::FixedDecimal;

fn format_number(amount: f64, locale_str: &str) -> String {
    // Note: ICU4X supports a fixed set of compiled locales
    // For dynamic locale selection, use the data provider
    let locale = locale!("de"); // German locale
    
    let fdf = FixedDecimalFormatter::try_new(
        &locale.into(),
        Default::default(),
    ).expect("locale data should be present");
    
    let decimal = FixedDecimal::from(amount as i64);
    fdf.format_to_string(&decimal)
}

Plural Rules

use icu::plurals::{PluralRules, PluralRuleType, PluralCategory};
use icu::locid::locale;

fn get_plural_category(count: f64, locale_str: &str) -> PluralCategory {
    // English plural rules
    let locale = locale!("en");
    let pr = PluralRules::try_new(
        &locale.into(),
        PluralRuleType::Cardinal,
    ).expect("locale data should be present");
    
    pr.category_for(FixedDecimal::from(count as i64))
}

fn translate_items(count: i64, locale: &str) -> &'static str {
    let category = get_plural_category(count as f64, locale);
    match (locale, category) {
        ("en", PluralCategory::One) => "1 item",
        ("en", _) if count == 0 => "No items",
        ("en", _) => "items", // placeholder, real app would format
        _ => "items",
    }
}

Handling Translation Files at Runtime vs. Compile Time

Rust gives you a choice between compile-time and runtime translation loading:

use std::collections::HashMap;

// Embed translation files at compile time
static EN_TRANSLATIONS: &str = include_str!("../locales/en.json");
static FR_TRANSLATIONS: &str = include_str!("../locales/fr.json");

fn load_translations() -> HashMap<String, HashMap<String, String>> {
    let mut translations = HashMap::new();
    translations.insert(
        "en".to_string(),
        serde_json::from_str(EN_TRANSLATIONS).expect("Valid JSON"),
    );
    translations.insert(
        "fr".to_string(),
        serde_json::from_str(FR_TRANSLATIONS).expect("Valid JSON"),
    );
    translations
}

Compile-time embedding produces a single binary with no runtime dependencies on locale files. This is ideal for CLI tools and embedded applications.

use std::collections::HashMap;
use std::path::Path;

fn load_translations_from_dir(dir: &Path) -> HashMap<String, HashMap<String, String>> {
    let mut translations = HashMap::new();
    
    for entry in std::fs::read_dir(dir).expect("locale dir exists") {
        let entry = entry.expect("valid dir entry");
        let path = entry.path();
        
        if path.extension().map_or(false, |ext| ext == "json") {
            let locale = path
                .file_stem()
                .expect("file has stem")
                .to_string_lossy()
                .to_string();
            
            let content = std::fs::read_to_string(&path)
                .expect("locale file readable");
            
            let strings: HashMap<String, String> = 
                serde_json::from_str(&content).expect("valid JSON");
            
            translations.insert(locale, strings);
        }
    }
    
    translations
}

Runtime loading allows updating translations without recompiling—useful for web services where translations are updated frequently.

WebAssembly and Rust i18n

Rust compiles to WebAssembly, and Rust i18n libraries work in WASM with some constraints:

  • File system access is unavailable in browser WASM environments
  • Compile-time embedding (include_str!) works well for WASM
  • ICU4X is specifically designed to work in constrained environments including WASM
#[cfg(target_arch = "wasm32")]
mod wasm {
    use wasm_bindgen::prelude::*;
    use rust_i18n::t;
    
    rust_i18n::i18n!("locales");
    
    #[wasm_bindgen]
    pub fn translate(key: &str, locale: &str) -> String {
        rust_i18n::set_locale(locale);
        t!(key)
    }
}

i18n for Rust CLIs

CLI applications detect system locale from environment variables:

use std::env;

pub fn detect_system_locale() -> String {
    // Priority: LANG > LC_ALL > LC_MESSAGES > default
    for var in &["LANG", "LC_ALL", "LC_MESSAGES"] {
        if let Ok(val) = env::var(var) {
            // LANG is typically "en_US.UTF-8"
            let locale = val
                .split('.')  // Remove encoding suffix
                .next()
                .unwrap_or("en")
                .replace('_', "-");  // Convert to BCP 47 (en_US → en-US)
            
            if !locale.is_empty() {
                return locale;
            }
        }
    }
    
    // Windows: use GetUserDefaultLocaleName via windows crate
    #[cfg(target_os = "windows")]
    {
        // ... Windows locale detection
    }
    
    "en".to_string()
}

Testing Rust i18n

#[cfg(test)]
mod tests {
    use rust_i18n::t;

    #[test]
    fn test_english_plurals() {
        rust_i18n::set_locale("en");
        
        // Test all plural forms
        assert_eq!(t!("items", count = 0), "No items");
        assert_eq!(t!("items", count = 1), "One item");
        assert_eq!(t!("items", count = 5), "5 items");
    }

    #[test]
    fn test_french_translation() {
        rust_i18n::set_locale("fr");
        
        assert_eq!(t!("welcome"), "Bienvenue !");
        assert_eq!(t!("greeting", name = "Marie"), "Bonjour, Marie !");
    }

    #[test]
    fn test_fallback_to_english() {
        // Locale with no translation file falls back to English
        rust_i18n::set_locale("xx");
        
        assert_eq!(t!("welcome"), "Welcome!"); // English fallback
    }
}

For comprehensive testing strategies applicable to Rust, see i18n testing tools, strategies, and automation.

Choosing Between Fluent and rust-i18n

CriterionFluentrust-i18n
Linguistic expressivenessHighMedium
Translator-friendlyVeryModerate
Developer ergonomicsMore verboseSimple macros
Plural handlingNative in FTLKey suffixes
Community adoptionGrowingMore adoption
Best forComplex apps, community translationSimpler apps, developer teams

For applications with complex linguistic needs or large translator communities, Fluent's expressiveness is worth the additional setup. For simpler apps or developer-managed translations, rust-i18n's ergonomics win.


Take your app global with better-i18n

better-i18n combines AI-powered translations, git-native workflows, and global CDN delivery into one developer-first platform. Stop managing spreadsheets and start shipping in every language.

Get started free → · Explore features · Read the docs