LaunchKit
← All posts
· 18 min read · by The LaunchKit team · 0 views

Rails i18n: lookup, fallbacks, plurals, dates

The LaunchKit boilerplate puts every user-facing string in config/locales/en.yml, 1160 lines of it, and the file opens with a comment promising that "translating the app later means adding a sibling file (e.g. fr.yml, zh.yml) with the same structure". The first half of that sentence is true and the work behind the second half is the subject of this post. A sibling file is necessary. It is nowhere near sufficient, and the gap is not in the YAML. Rails i18n is a dozen small behaviours that all sleep until I18n.locale can be something other than :en, and several of them fail by returning the wrong string rather than by raising.

Everything below was reproduced against rails 8.1.3.1 and i18n 1.15.2, in this site's own bin/rails runner, and the output is pasted as it came back.

Where the strings live, and what a leading dot resolves to

A locale file is a YAML tree under a locale key, and I18n.t("auth.sessions.new.title") walks it. Writing the full path in every view gets old fast, so Action View offers the leading dot:

<h1><%= t(".title") %></h1>

The resolution is four lines, in actionview-8.1.3.1/lib/action_view/helpers/translation_helper.rb:

def scope_key_by_partial(key)
  if key&.start_with?(".")
    if @virtual_path
      @_scope_key_by_partial_cache ||= {}
      @_scope_key_by_partial_cache[@virtual_path] ||= @virtual_path.gsub(%r{/_?}, ".")
      "#{@_scope_key_by_partial_cache[@virtual_path]}#{key}"

The template's own path, with slashes turned into dots and a partial's leading underscore eaten by the same substitution. Rendering people/index.html.erb and people/_card.html.erb against a scratch view path confirmed both halves:

lazy:         All people                 # people.index.title
partial lazy: Card label                 # people.card.label

Two consequences follow from the key being derived from the file path. Moving a partial renames every lazy key inside it, silently, and the symptom is a missing translation rather than an exception. And @virtual_path only exists inside a template, so the same call from a helper module or a plain object raises:

RuntimeError: Cannot use t(".x") shortcut because path is not available

Worth knowing that the boilerplate does not use i18n lazy lookup at all. Zero occurrences of t(". in app/, against 1009 full-key calls in its views. Full keys are greppable, which is the whole argument for them, and the one that matters once a scanner has to find every key you wrote.

Lazy lookup means something else in a controller

AbstractController::Translation#translate also accepts a leading dot, and it does not do the same thing:

def translate(key, **options)
  if key&.start_with?(".")
    path = controller_path.tr("/", ".")
    defaults = [:"#{path}#{key}"]
    defaults << options[:default] if options[:default]
    options[:default] = defaults.flatten
    key = "#{path}.#{action_name}#{key}"
  end

Two keys, not one. PeopleController#index calling t(".shared_msg") asks for people.index.shared_msg and, when that is missing, falls through to people.shared_msg. Verified on a live controller instance:

c.t(".shared_msg")  # => "controller-level key"   (stored at people.shared_msg only)
c.t(".title")       # => "All people"             (stored at people.index.title)

The controller level is where flash messages shared by create and update belong, and the framework hands you that scope for free. A view has no equivalent. t(".title") in people/index.html.erb checks exactly one key and gives up.

Nothing in the guides puts these two methods side by side, and the asymmetry is the kind of thing you discover by moving a t call from a controller into a view and watching a working string turn into a translation_missing span.

Three environments, three answers to a missing key

Running the same I18n.t("nope.nope") in each environment of this site, with ActionView::Base forced to load so the on_load(:action_view) hook fires:

env=development
  view.raise_on_missing_translations = nil
  I18n.exception_handler = I18n::ExceptionHandler
  fallbacks backend included = false
  I18n.t missing: "Translation missing: en.nope.nope"

env=test
  view.raise_on_missing_translations = true
  I18n.exception_handler = Proc
  I18n.t missing: I18n::MissingTranslationData: Translation missing: en.nope.nope

env=production
  view.raise_on_missing_translations = nil
  fallbacks backend included = true
  I18n.fallbacks = #<I18n::Locale::Fallbacks @map={} @defaults=[:en]>
  I18n.t missing: "Translation missing: en.nope.nope"

Three configurations, all of them straight out of rails new. The test one is the most aggressive and the mechanism is worth seeing, because it is global rather than scoped to views:

if app.config.i18n.raise_on_missing_translations &&
    I18n.exception_handler.is_a?(I18n::ExceptionHandler)
  I18n.exception_handler = ->(exception, *) {
    exception = exception.to_exception if exception.is_a?(I18n::MissingTranslation)
    raise exception
  }
end

That is activesupport-8.1.3.1/lib/active_support/i18n_railtie.rb. Setting the flag replaces the process-wide exception handler, so I18n.t raises in a model, a job and a rake task too, not only in a rendered template.

In development and production a missing key inside a view becomes markup:

<span class="translation_missing" title="translation missing: en.people.index.nope">Nope</span>

The text is keys.last.to_s.titleize, which is why a missing en.people.index.nope renders the word "Nope" on the page. No CSS in a generated app styles that class, so a user sees a plausible looking label and reports nothing. Setting ActionView::Base.debug_missing_translation = false swaps the span for the bare title string, which is louder and uglier and arguably correct for staging.

The missing translation that never announces itself

Two lookups in Rails carry a built-in default, so they never go missing and never raise, whatever raise_on_missing_translations says.

ActiveModel::Translation#human_attribute_name humanizes the attribute name. In this site's test environment, the one with the raising exception handler installed:

Lead.human_attribute_name(:nickname)        # => "Nickname"
Lead.human_attribute_name(:made_up_column)  # => "Made up column"

No raise, no span, no difference between a key you wrote and a column you never translated. Catching that one needs config.i18n.raise_on_missing_translations = :strict, which is the only value that reaches ActiveModel::Translation.raise_on_missing_translations in the railtie. The plain true covers views and the global handler, and leaves model naming alone.

Action Mailer has the same shape:

def default_i18n_subject(interpolations = {})
  mailer_scope = self.class.mailer_name.tr("/", ".")
  I18n.t(:subject, **interpolations, scope: [mailer_scope, action_name], default: action_name.humanize)
end

A ConfirmationMailer#confirm with no confirmation_mailer.confirm.subject key sends an email whose subject line is the word "Confirm". Delivered, accepted, logged as a success, and wrong in every locale including the one you wrote. The other ways a mailer goes quietly wrong in production are in Action Mailer in production; this is the i18n-shaped one.

Turning fallbacks on

config.i18n.fallbacks = true is generated into production.rb and nowhere else, in both repositories behind this site, straight from the Rails 8.1 app template. What the flag does is one method:

def self.init_fallbacks(fallbacks)
  include_fallbacks_module

  args = \
    case fallbacks
    when ActiveSupport::OrderedOptions
      [*(fallbacks[:defaults] || []) << fallbacks[:map]].compact
    when Hash, Array
      Array.wrap(fallbacks)
    else # TrueClass
      [I18n.default_locale]
    end

  I18n.fallbacks = I18n::Locale::Fallbacks.new(*args)
end

include_fallbacks_module includes I18n::Backend::Fallbacks into the backend class, and the Fallbacks.new call computes chains. A chain is the locale, then its parents, then the defaults:

chain :fr        = [:fr, :en]
chain :'fr-CA'   = [:"fr-CA", :fr, :en]
chain :'zh-TW'   = [:"zh-TW", :zh, :en]

Two details in that method matter more than the chains. The TrueClass branch reads I18n.default_locale once, at boot, and freezes the answer into the fallback object: setting I18n.default_locale = :fr afterwards left I18n.fallbacks[:de] as [:de, :en]. And the richer form, config.i18n.fallbacks = { ca: :es, "de-AT": :de }, goes through Array.wrap, so a Hash is a map rather than a default list.

Enabling fallbacks in production and not in development is the default for a reason: the developer sees the hole and the user does not. Enabling them in test as well would make an untranslated app pass its whole suite.

An empty string is a translation

i18n fallbacks walk the chain until something answers, and the test for "answers" is one word:

catch(:exception) do
  result = super(fallback, key, fallback_options)
  unless result.nil?
    on_fallback(locale, fallback, key, options) if locale.to_s != fallback.to_s
    return result
  end
end

unless result.nil?. Not blank?. So a half-finished fr.yml carrying empty: "" returns the empty string and the chain stops:

I18n.t("demo.bye",   locale: :fr)  # => "Goodbye"   (key absent, falls back)
I18n.t("demo.empty", locale: :fr)  # => ""          (key present and empty, no fallback)

A translator handed a spreadsheet leaves cells blank, an export writes them as "", and the French page renders a heading-shaped hole where the English one had words. Missing keys are the failure mode everybody plans for; present-and-empty keys are the one that survives a round of review, because the YAML looks complete.

Dates, the l helper, and the format keys

l delegates straight to I18n.localize, which does something more interesting than strftime:

if Symbol === format
  key  = format
  type = object.respond_to?(:sec) ? 'time' : 'date'
  options = options.merge(:raise => true, :object => object, :locale => locale)
  format  = I18n.t(:"#{type}.formats.#{key}", **options)
end

format = translate_localization_format(locale, object, format, options)
object.strftime(format)

A symbol format is itself a translation key. date or time is chosen by whether the object responds to sec, which is how a Date and a Time reach different subtrees. Active Support ships the English half in active_support/locale/en.yml:

en:
  date:
    formats:
      default: "%Y-%m-%d"
      short: "%b %d"
      long: "%B %d, %Y"
  time:
    formats:
      default: "%a, %d %b %Y %H:%M:%S %z"
      short: "%d %b %H:%M"
      long: "%B %d, %Y %H:%M"

Three date formats and three time formats, and that is the entire i18n date format vocabulary a new app has. Anything else is a key you add. Measured on 2026-09-24:

I18n.l(Date.new(2026, 9, 24))                      # => "2026-09-24"
I18n.l(Date.new(2026, 9, 24), format: :long)       # => "September 24, 2026"
I18n.l(Time.utc(2026, 9, 24, 15, 4, 5))            # => "Thu, 24 Sep 2026 15:04:05 +0000"
I18n.l(Date.new(2026, 9, 24), format: "%A %-d %B %Y") # => "Thursday 24 September 2026"
I18n.l(Date.new(2026, 9, 24), format: :fancy)
# => I18n::MissingTranslationData: Translation missing: en.date.formats.fancy
I18n.l(nil)
# => I18n::ArgumentError: Object must be a Date, DateTime or Time object. nil given.

The :raise => true in that merge is why a typo'd format symbol raises rather than rendering a span. An l(record.published_at) on a nil column raises too, with an ArgumentError whose message names the class you actually passed, which is the friendliest error in this post.

The French page that renders an English date

Turn fallbacks on, ask for a French date, and read the answer twice.

I18n::Backend::Simple.include(I18n::Backend::Fallbacks)
I18n.fallbacks = I18n::Locale::Fallbacks.new(I18n.default_locale)

I18n.l(Date.new(2026, 9, 24), locale: :fr, format: :long)
# => "September 24, 2026"

September, in a page whose <html lang> says fr. The chain is doing exactly what it was asked to. fr.date.formats.long is missing, so the lookup falls back to en.date.formats.long and returns "%B %d, %Y". Then translate_localization_format expands %B through I18n.t!(:"date.month_names", locale: :fr), which is also missing, which also falls back. Month name and field order both come from English, so the French reader gets September 24, 2026 where 24 septembre 2026 was intended. Put rails-i18n's fr.yml on the load path and the same call returns "24 septembre 2026", which is how you know the only thing missing was data.

Without fallbacks the same call raises I18n::MissingTranslationData: Translation missing: fr.date.formats.long, which is the honest answer and the one you want in CI.

There is a third behaviour, and it is the worst of the three. With a literal strftime string instead of a symbol, the format is never looked up, so the lookup that fails is the month or day name:

I18n.l(Date.new(2026, 9, 24), locale: :fr, format: "%A")
# => "Translation missing: fr.date.day_names"

A String. Not an exception, not a span, the message text of an error used as the return value, ready to be printed inside a <td>. The cause is a bare rescue at the bottom of the method:

rescue MissingTranslationData => e
  e.message
end

i18n-1.15.2/lib/i18n/backend/base.rb, line 304. Adding a locale file with date.formats and date.month_names in it fixes all three, and rails-i18n exists so that you do not write those by hand: its rails/locale/fr.yml carries long: "%-d %B %Y" and the month names, and it ships 123 of those files alongside 111 pluralization rules.

Pluralisation past one and other

The pluralisation key is chosen by three lines in i18n/backend/base.rb:

def pluralization_key(entry, count)
  key = :zero if count == 0 && entry.has_key?(:zero)
  key ||= count == 1 ? :one : :other
end

So :zero is supported, conditionally: it is used when the count is zero and you wrote the key. Leave it out and zero takes :other, which is why the default English rendering is "0 apples". Adding it is the cheapest copy improvement in any list view, because "no files" reads better than "0 files" in every language that has the form.

Four behaviours worth having pinned down, all measured:

I18n.t("demo.files",  count: 0)  # => "no files"       (:zero present)
I18n.t("demo.apples", count: 0)  # => "0 apples"       (:zero absent)
I18n.t("demo.apples", count: 1.0)# => "one apple"      (1.0 == 1)
I18n.t("demo.apples", count: nil)# => {one: "one apple", other: "%{count} apples"}
I18n.t("demo.broken", count: 5)
# => I18n::InvalidPluralizationData: translation data {one: "just one"} can not be
#    used with :count => 5. key 'other' is missing.

The count: nil row is the one that reaches production. Passing count: order.items_count where the column is nullable does not raise and does not pluralize; pluralize returns early on return entry unless entry.is_a?(Hash) && count, and the Hash gets interpolated into your template as {one: ..., other: ...}. Guard the count, not the key.

Russian wants four forms and Rails ships rules for none

English has two plural forms and the stock I18n::Backend::Simple knows exactly those two. Russian has four, and the difference is not decorative: the form depends on the last digit and on whether the last two digits fall in the teens.

Stock backend, with one, few, many and other all present in the translation:

ru books 1:  "1 книга"     ok
ru books 2:  "2 книги"     right, but only because :other and :few carry the same string here
ru books 5:  "5 книги"     wrong, should be "5 книг"
ru books 11: "11 книги"    wrong, should be "11 книг"
ru books 21: "21 книги"    wrong, should be "21 книга"

The few and many keys sit in the file, correctly written, and nothing reads them. Including I18n::Backend::Pluralization and storing rails-i18n's East Slavic rule fixes every row:

ru books 5:  "5 книг"
ru books 11: "11 книг"
ru books 21: "21 книга"
ru books 22: "22 книги"

The rule lives at rails-i18n-8.1.0/lib/rails_i18n/common_pluralizations/east_slavic.rb and is stored as data, under the reserved i18n.plural.rule key in the locale itself, alongside i18n.plural.keys listing [:one, :few, :many, :other]. Adding rails-i18n to a Gemfile does the wiring: its railtie ends with init_pluralization_module, which is I18n.backend.class.send(:include, I18n::Backend::Pluralization).

All of which makes rails-i18n 8.1.0 the first gem to add for a second locale, ahead of any tooling. 141 million downloads, MIT, released 2025-11-24, Ruby 3.2 or newer, maintained by the Rails I18n Group. Its railtie holds one trap: pattern_from app.config.i18n.available_locales expands to * when you have not declared your locales, and it then concatenates all 123 of its locale files onto I18n.load_path. Declare config.i18n.available_locales = [:en, :fr] and it loads two.

available_locales is whatever happens to be on the load path

I18n.available_locales is derived, not declared. Asking this site, which has exactly one locale file:

[:en, :ar, :bg, :"ca-CAT", :ca, :"da-DK", :"de-AT", :"de-CH", :de, :ee, :"en-AU", :"en-BORK",
 ... 59 entries in all ...
 :uk, :vi, :"zh-CN", :"zh-TW"]

Fifty-nine locales, derived from I18n.load_path, which holds 325 files. 318 of them belong to faker 3.8.0, whose lib/locales/ directory joins the load path in every environment where the gem is loaded, including test. I18n.enforce_available_locales is true by default and I18n.t("x", locale: :xx) duly raises I18n::InvalidLocale: :xx is not a valid locale, so the check works. What the check is checking is not what anyone means by "the locales this app supports".

Anything that branches on I18n.available_locales.include?(locale) is therefore branching on your Gemfile. The boilerplate does exactly that, in Pricing::Plan#i18n_copy, to guard against an unknown locale coming out of stored config. Correct in intent and looser than it looks. The fix is one line in application.rb, config.i18n.available_locales = [:en], which also narrows what rails-i18n loads and turns a locale allowlist into something you can read.

What a second locale moves besides the YAML

This site is English only and says so in its own SEO rules: "Locale | English only (config/locales/en.yml). Conversation may be French; artifacts never are." Being straight about the size of the job is more useful than a migration guide nobody follows, so here is the list, from the parts of both repositories that would have to change.

Deciding the locale, and carrying it. Something has to set I18n.locale per request, from a URL segment, a subdomain, a user column or Accept-Language, and I18n.locale is fiber-local: i18n 1.15.2 stores its config in Fiber[:i18n_config] and dups it when a child fiber touches it. A locale in the path also means default_url_options returning { locale: I18n.locale } in every controller, or every link_to in the app loses it.

Background work already carries it, mostly. ActiveJob::Core#serialize writes "locale" => locale || I18n.locale.to_s into the job payload and ActiveJob::ExecutionState#perform_now wraps super in I18n.with_locale(locale). So a mailer enqueued from a French request is delivered in French whatever the adapter, which is a property of Active Job rather than of Solid Queue or Sidekiq. A recurring job has no request to inherit from and runs in I18n.default_locale; a digest email built there needs I18n.with_locale around the recipient.

Content in the database is not i18n's problem. The boilerplate already hit this and solved it outside the framework: Pricing::Plan#copy walks its own chain, documented in the source as "DB[locale] -> DB[default_locale] -> locale file -> default locale", because plan names are edited in an admin form rather than committed in YAML. Every editable model reaches the same fork: a jsonb blob keyed by locale, a translations table, or a gem.

The half of a second locale that is not strings

Numbers move first. number_to_currency reads number.currency.format.format out of the locale, and rails-i18n's fr.yml sets it to "%n %u" with separator: ",", delimiter: " " and unit: "€", against Active Support's English "%u%n". With fallbacks on and no French locale file, the same call in both locales:

ActiveSupport::NumberHelper.number_to_currency(1234.5, locale: :fr)  # => "$1,234.50"
ActiveSupport::NumberHelper.number_to_currency(1234.5, locale: :en)  # => "$1,234.50"

Add rails-i18n's fr.yml to the load path and the first one becomes "1 234,50 €". Dollars on a French page is the same failure as September on a French page, one layer over.

The parts that are not Ruby. An hreflang cluster in the layout, a sitemap that lists every URL once per locale, a canonical strategy that does not collapse them, and, if any string is rendered client-side, an export step. i18n-js 4.5.3 on npm with the 4.2.4 gem is the usual pair: the gem exports the YAML to JSON from a config/i18n.yml, and the npm package is the runtime that reads it. Neither repository here has a strings-in-JavaScript problem, so neither has the gem.

Finding the holes. i18n-tasks 1.1.2, MIT, released 2025-11-27, 33.5 million downloads, maintained by glebm. i18n-tasks missing and i18n-tasks unused are the two commands that make a second locale maintainable, and they are the reason the boilerplate's 1009 full-key calls are worth something: a static scanner can find t("home.index.title") and cannot always find t(".title").

The call, and what would change it

Keep one locale file until a second locale has a date attached, and spend the effort on keeping every string in it rather than on preparing for a translation that may never be scheduled. The boilerplate's position, all copy in en.yml and no locale switching anywhere, is the right default for a product sold to solo developers shipping in English.

When the second locale does arrive, three things go in before the first translated string: rails-i18n for the plural rules and the date formats, config.i18n.available_locales so the allowlist means something, and i18n-tasks in CI so a missing key fails a build instead of rendering a titleized span. Fallbacks go in last and grudgingly, because they convert a loud failure into a quiet wrong answer, and the English date on the French page is what that trade looks like in a screenshot.

What would change the recommendation: a fallbacks option that distinguished "no translation" from "no localisation", so a missing fr.date.formats.long raised while a missing UI string fell back, would remove the only real objection to turning them on everywhere. A raise_on_missing_translations that covered human_attribute_name under plain true rather than only under :strict would close the widest hole in the current defaults.

The cost of the position is paid in rework. Strings written with no second locale in view acquire habits that do not survive one: concatenated sentence fragments, counts spliced in without a plural key, a date formatted with a literal strftime instead of l. None of those raise, and all of them have to be found by hand later. The boilerplate has the third: app/views/landings/templates/newspaper.html.erb line 5 renders Date.current.strftime("%B %-d, %Y"), which is correct English and stays English forever. Ten l( calls across its views against that one. Cheap to fix at 1160 lines, less cheap at 4000.

What this post does not cover

Model attribute translation, meaning a title_fr column or a translations table, which is the mobility and globalize territory and a different post. Right-to-left layout, which is CSS and asset pipeline work that i18n does not touch. Locale-aware routing constraints and the SEO question of subdirectory against subdomain against ccTLD. Transliteration, parameterize and slugs in a non-Latin script. And any benchmark, because nothing measured here is slow; the failures are wrong output, not slow output.

The page-number bar that pagination without a gem declines to write by hand is one of the few places a Rails app hits i18n and pluralisation in the same line of markup, and that post makes the opposite call on the same trade: write the query yourself, buy the view layer.

#rails #i18n

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.