Rails i18n: the complete guide

Setting up Rails internationalization properly: locale files, interpolation, pluralization, lazy lookup, fallbacks, and syncing translations from CI.

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:

yaml
# config/locales/en.yml
en:
  dashboard:
    title: "Dashboard"
    welcome: "Welcome back, %{name}"

And are looked up with t:

erb
<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:

ruby
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

ruby
# 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:

ruby
# 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:

ruby
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}:

yaml
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:

yaml
en:
  progress: "%<percent>.1f%% complete"

Pluralization

Pass count and nest the categories:

yaml
en:
  messages:
    one: "1 message"
    other: "%{count} messages"
ruby
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:

erb
<%# 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 structureposts.index.title. Pairs naturally with lazy lookup, keys are obvious from location, and moving a view means moving keys.

Group by meaningerrors.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:

yaml
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 than status.humanize.

Syncing translations

Once translation happens outside the repository you need the files to move automatically, or they drift. With WebTranslateIt’s CLI:

bash
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:

bash
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_path widened to include subdirectories.
  • raise_on_missing_translations on in development and test.
  • Fallbacks configured for production.
  • I18n.with_locale in an around_action, never a bare assignment.
  • rails-i18n added 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

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.