When hardcoded strings stop working
You shipped a command-line tool that parses logs. It works perfectly. A colleague sends a screenshot: "Can we run this in German?" You stare at your codebase. Every error message, every help text, every status update is a hardcoded string buried in logic. Rewriting the app to support multiple languages feels like a refactor nightmare. You need a way to separate text from code, handle pluralization, and swap languages without touching your business logic.
Text as data, not code
Localization frameworks treat user-facing text as data. You write your application logic in Rust, but you move all strings into separate resource files. The fluent-rs crate implements the Fluent localization system, originally built by Mozilla. It uses .ftl files that look like simple key-value pairs but support powerful features like variable interpolation, pluralization rules, and message fallback.
Think of your Rust code as a stage manager. The .ftl files are the scripts. Your code doesn't memorize lines. It tells the bundle which scene to perform and hands over the props. The bundle reads the script, fills in the variables, applies the correct grammar for the language, and returns the final string. Your code stays clean. The text lives where translators can edit it.
Minimal setup
You need three crates. fluent-bundle handles the bundle logic. intl-memoizer caches ICU data for pluralization and formatting. unic-langid provides language identifiers.
[dependencies]
fluent-bundle = "0.16"
intl-memoizer = "0.5.1"
unic-langid = { version = "0.9.0", features = ["macros"] }
The langid! macro generates a LanguageIdentifier at compile time. This is the standard way to define locales in the Rust ecosystem. It avoids runtime parsing errors and makes your intent explicit.
use fluent_bundle::{FluentBundle, FluentResource, FluentValue, FluentArgs};
use intl_memoizer::concurrent::IntlLangMemoizer;
use unic_langid::langid;
fn main() {
// Define the language ID. This tells Fluent which language rules to apply.
let langid = langid!("en", "US");
// Create a concurrent bundle. This is safe to share across threads.
let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]);
// Load the Fluent resource string. In a real app, this comes from a file.
let res = FluentResource::try_new("hello = Hello, { $name }!".to_string()).unwrap();
bundle.add_resource(res).unwrap();
// Prepare arguments to interpolate into the message.
let mut args = FluentArgs::new();
args.set("name", FluentValue::from("World"));
// Resolve the message.
let msg = bundle.get_message("hello").unwrap();
let pattern = msg.value().unwrap();
let mut errors = vec![];
let mut out = String::new();
pattern.format(&args, &mut out, &mut errors);
println!("{}", out);
}
Keep your resource loading separate from your business logic. The bundle is your text factory.
How the bundle works
When you call FluentBundle::new_concurrent, you create a container that holds localization data. The concurrent variant wraps the internal state in a mutex, allowing multiple threads to resolve messages safely. You pass a vector of language IDs. This vector defines the fallback chain. If a message is missing in the first language, Fluent checks the next one.
Adding a resource parses the .ftl text into an internal representation. The parser validates syntax and extracts message IDs, patterns, and terms. If the syntax is invalid, try_new returns an error. You should handle this in production code. A malformed resource file shouldn't crash your app.
When you resolve a message, you retrieve the FluentMessage object. This object contains the pattern but hasn't interpolated anything yet. You create a FluentArgs map to hold runtime values. The format method walks the pattern, replaces variables with values from the args map, applies pluralization rules if needed, and writes the result to the output string. It also collects errors. If a variable is missing or a plural form is undefined, Fluent logs an error but still produces output. It never panics on missing data.
The intl-memoizer crate plays a quiet but essential role. Pluralization and date formatting rely on ICU data. Without memoization, Fluent would reload ICU data for every resolution. The memoizer stores the data in memory, making subsequent lookups instant. The concurrent variant is thread-safe and designed for applications that resolve messages from multiple threads.
Realistic usage with pluralization and terms
Real applications need pluralization and reusable vocabulary. Fluent handles both elegantly. Terms let you define reusable definitions. Messages reference terms using the - prefix. This keeps your translation consistent. If you change the translation of a term in one place, every message using that term updates automatically.
use fluent_bundle::{FluentBundle, FluentResource, FluentValue, FluentArgs};
use intl_memoizer::concurrent::IntlLangMemoizer;
use unic_langid::langid;
/// Localizes a message based on language and arguments.
/// Returns the formatted string.
fn get_download_status(lang: &str, count: i64) -> String {
let langid = langid!(lang);
let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]);
// Resource with terms and pluralization support.
let ftl = r#"
# The term for the item. Translators define this once.
-item = file
# The message with pluralization.
# Fluent applies CLDR rules based on the language ID.
download-count = You have { $count ->
[one] 1 { -item }
*[other] { $count } { -item }s
} to download.
"#;
let res = FluentResource::try_new(ftl.to_string()).unwrap();
bundle.add_resource(res).unwrap();
let mut args = FluentArgs::new();
args.set("count", FluentValue::from(count));
let msg = bundle.get_message("download-count").unwrap();
let pattern = msg.value().unwrap();
let mut errors = vec![];
let mut out = String::new();
pattern.format(&args, &mut out, &mut errors);
out
}
fn main() {
println!("{}", get_download_status("en", 1));
println!("{}", get_download_status("en", 5));
}
Pluralization is where localization gets hard. Let Fluent handle the grammar. You handle the count.
Pitfalls and compiler errors
Fluent handles missing data gracefully. If you forget to set an argument, the output contains a placeholder like { $name }. It doesn't crash. This is a design choice. Localization errors should degrade gracefully. However, you should check the errors vector after formatting. If it's not empty, your resource file or arguments are misaligned. Silent placeholders are worse than panics.
Pluralization rules vary by language. English has one and other. French has one, two, and many. Arabic has six forms. Fluent uses CLDR plural rules. You don't need to memorize them. Write the rules in the .ftl file, and Fluent applies the correct form based on the language ID. If you define a plural form that doesn't exist for the target language, Fluent picks the closest match or falls back to other.
The fallback chain is powerful. If you request de-AT but only have de, Fluent uses de. If you have en-US and en, and you request en-US, it checks en-US first, then en. This lets you maintain a base language and override specific strings for dialects.
If you try to pass a String directly to args.set, the compiler rejects you with E0308 (mismatched types). The set method expects a FluentValue. You must wrap your data using FluentValue::from. This wrapper handles type conversion and ensures the value matches the expected format for interpolation.
Performance matters in tight loops. Creating FluentArgs allocates. If you are resolving messages frequently, reuse the args map. Clear it and repopulate it. This reduces allocation pressure. The community convention is to keep the bundle in a global or application state and pass it down. Don't recreate the bundle for every message.
Convention asides
The community convention is to name .ftl files by locale, like en-US.ftl or de.ftl. Store them in a locales directory. This makes it easy to iterate over files and build bundles dynamically. When writing .ftl files, always include comments for translators. Use # for comments. Translators don't see your code. Comments provide context. "This is the button text for saving" is better than just save = Save.
When calling Rc::clone or similar patterns in your wrapper code, the convention is to be explicit. If you wrap the bundle in an Rc, write Rc::clone(&bundle) instead of bundle.clone(). This signals to readers that you are cloning the reference, not the data. It prevents confusion with deep clones.
When to use Fluent
Use fluent-rs when you need robust pluralization, variable interpolation, and message fallback in a single framework. Use fluent-rs when your application targets multiple languages with complex grammar rules, such as Slavic or Arabic languages that require extensive plural forms. Use fluent-rs when you want a modern localization format that separates terms from messages and supports rich text patterns. Reach for simple key-value maps when your app has only a few static strings and no pluralization needs. Reach for gettext bindings when you are maintaining a legacy codebase that already uses .po files and you cannot migrate to Fluent. Reach for i18n-embed when you want a higher-level abstraction that handles file loading, caching, and fallback configuration automatically on top of fluent-rs.
Pick the tool that matches your complexity. Don't over-engineer a hello world, but don't under-engineer a global product.