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.
Comments
No comments yet. Be the first.