Rails ships with a capable i18n framework and almost no opinions about how to use it. This guide covers the setup that holds up past the first few hundred strings.
The basics
Translations live in config/locales, keyed by locale:
# config/locales/en.yml
en:
dashboard:
title: "Dashboard"
welcome: "Welcome back, %{name}"
And are looked up with t:
<h1><%= t("dashboard.title") %></h1>
<p><%= t("dashboard.welcome", name: current_user.first_name) %></p>
The load path trap
Rails loads config/locales/*.{rb,yml} — the top level only. Put a file in config/locales/admin/en.yml and it is silently ignored. This is the most common reason a translation that obviously exists appears to be missing.
Fix it in config/application.rb:
config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.{rb,yml}")]
Do this early. Once you have more than a few hundred keys you will want subdirectories, and discovering this rule at that point means moving files around.
Configuration worth setting
# config/application.rb
config.i18n.available_locales = %i[en fr de]
config.i18n.default_locale = :en
config.i18n.fallbacks = [:en]
available_locales combined with enforce_available_locales — on by default — means an unexpected locale raises rather than silently rendering nothing. Keep it.
Raise on missing translations in development and test:
# config/environments/development.rb and test.rb
config.i18n.raise_on_missing_translations = true
This is the highest-value setting in this guide. Without it a missing key renders a yellow translation missing span that nobody notices in a language they do not read. With it, a missing key fails the test that renders the view.
In production, keep fallbacks on so a gap degrades to the default locale rather than showing markup to a user.
Setting the locale
Around every request, and reset afterwards:
class ApplicationController < ActionController::Base
around_action :switch_locale
private
def switch_locale(&)
I18n.with_locale(locale_from_request, &)
end
def locale_from_request
params[:locale] ||
current_user&.locale ||
http_accept_language.compatible_language_from(I18n.available_locales) ||
I18n.default_locale
end
end
Use I18n.with_locale rather than assigning I18n.locale =. The assignment is thread-local and persists into the next request handled by that thread, which produces the memorable bug where one user occasionally sees another user’s language.
Interpolation
Rails uses %{name}:
en:
invitation: "%{inviter} invited you to %{project}"
Named placeholders rather than positional ones, which matters because word order differs between languages — see the placeholder formats cheat sheet.
Two things to know. A missing interpolation argument raises I18n::MissingInterpolationArgument, which is the behaviour you want. And a literal % in a string with interpolation must be escaped as %%.
For formatted values use %<name>s with a format specifier:
en:
progress: "%<percent>.1f%% complete"
Pluralization
Pass count and nest the categories:
en:
messages:
one: "1 message"
other: "%{count} messages"
t("messages", count: 5) # => "5 messages"
The critical point: the categories required depend on the target language, not on English. Polish needs one, few, many, other; Arabic needs six. A Polish file with only one and other will raise I18n::InvalidPluralizationData for most numbers.
Rails ships with English rules only. For anything else add the rails-i18n gem, which carries CLDR pluralization for a long list of locales, or the i18n gem’s pluralization backend. See plural rules by language for what each language needs.
Note also that zero is supported by Rails as a convenience key and is checked before the CLDR category when count is 0 — useful for wording an empty state differently, but not a CLDR category in most languages.
Lazy lookup
Inside a view, a leading dot resolves relative to the template path:
<%# app/views/posts/index.html.erb %>
<h1><%= t(".title") %></h1> <%# => posts.index.title %>
It keeps keys short and mirrors your structure. The cost is that posts.index.title never appears literally in the codebase, so grepping for a key you found in a locale file returns nothing. Pick one convention and apply it consistently — mixing the two is what makes keys genuinely hard to trace.
The same works in controllers for flash messages, resolving against controller.action.
Structuring keys
Two schools, and the choice matters more than which one you pick:
Mirror the view structure — posts.index.title. Pairs naturally with lazy lookup, keys are obvious from location, and moving a view means moving keys.
Group by meaning — errors.card_declined, nav.settings. Encourages reuse, survives refactoring, and needs deliberate naming.
In practice most applications end up with both: structural keys for page-specific copy and a shared namespace for anything reused. What matters is that a shared string lives in exactly one place — the same sentence duplicated under three keys is three translations to pay for and three chances to drift.
Models and attributes
Rails looks up model and attribute names automatically:
en:
activerecord:
models:
user:
one: "User"
other: "Users"
attributes:
user:
email: "Email address"
errors:
models:
user:
attributes:
email:
blank: "is required"
This is what makes validation error messages translatable without touching the model. Worth setting up early, because retrofitting it means auditing every custom error message.
Views, mailers and everything else
The strings developers forget, in the order they are usually forgotten:
- Mailer subjects and bodies. Set the locale from the recipient’s preference, not from the current request — a background job sending a notification has no request locale.
- Background jobs. Same issue. Pass the locale explicitly into the job arguments.
- PDF and CSV exports. Frequently built separately and never internationalized.
- JSON API error messages, if they are ever surfaced to a user.
- Seed data and enum labels.
t("statuses.#{status}")rather thanstatus.humanize.
Syncing translations
Once translation happens outside the repository you need the files to move automatically, or they drift. With WebTranslateIt’s CLI:
wti push # send config/locales/en.yml up
wti pull # bring the translated locales back
wti status # per-language completeness
wti diff # what a push would change, without pushing
Wire it into CI: push source strings on merge to your designated branch, pull translations nightly into a pull request. wti diff as a pull request check is the piece worth adding early — it surfaces “this refactor deletes 40 translated segments” while the change is still being reviewed.
A .wti file at the repository root holds the project token and the file mapping, so the configuration travels with the code. See Git-based localization workflows for the branch strategy that keeps pushes from fighting each other.
Keeping the files honest
Two problems accumulate quietly: keys referenced in code that do not exist in any locale file, and keys sitting in locale files that nothing references any more. Both are invisible until someone hits the missing one in production.
The i18n-tasks gem finds both:
i18n-tasks missing # referenced in code, absent from locale files
i18n-tasks unused # present in locale files, referenced nowhere
i18n-tasks normalize # sort keys consistently so diffs stay readable
i18n-tasks health # everything at once
Add i18n-tasks missing to CI and a missing key becomes a failed build rather than a support ticket. normalize is worth running too — without a consistent key order, every regenerated locale file produces a diff touching every line, which makes translation pull requests unreviewable.
The one caveat is that dynamic keys — t("statuses.#{status}") — look unused to static analysis. i18n-tasks supports declaring those patterns in its config so they stop being reported.
A checklist
config.i18n.load_pathwidened to include subdirectories.raise_on_missing_translationson in development and test.- Fallbacks configured for production.
I18n.with_localein anaround_action, never a bare assignment.rails-i18nadded if you support any non-English locale.- Plural categories generated per target language, not copied from English.
- Mailers and jobs taking an explicit locale.
- Sync automated in both directions.
Frequently asked questions
- Where do Rails locale files go?
- In config/locales. Rails only loads config/locales/*.{rb,yml} by default — files in subdirectories are ignored unless you add them to config.i18n.load_path, which is the single most common reason a translation appears to be missing.
- How do I pluralize in Rails i18n?
- Pass a count option and nest the plural categories under the key. Rails selects the category using the locale's pluralization rules, so a Polish key needs one, few, many and other rather than the one and other an English key needs.
- What is lazy lookup in Rails?
- Inside a view, t('.title') with a leading dot resolves relative to the template path — in app/views/posts/index.html.erb it looks up posts.index.title. It keeps keys short and mirrors your view structure, at the cost of making keys harder to grep for.
- How do I handle missing translations in Rails?
- Configure fallbacks so a missing key falls back to the default locale rather than rendering a translation-missing span. In development and test, raise on missing translations instead, so the gap fails loudly where somebody will see it.
Keep reading
-
Plural rules by language: the complete CLDR guide
A reference table of CLDR plural categories for 163 languages, plus why one does not mean 1 and how each i18n framework expects you to spell the rules.
-
Placeholder formats cheat sheet: %s, %@, %{name}, {{name}}, {0}
Every string placeholder syntax you will meet, which language or framework uses it, and what breaks when a translator retypes one by hand.
-
Continuous localization explained
How to run translation as a continuous process alongside development, what to do about feature branches, and when agile localization is the better fit.
-
Translate YAML files (documentation)
How WebTranslateIt parses Rails locale files, including plural rules and type casting.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.