How to Translate YAML Files (Rails, Symfony and Beyond)
Translating YAML locale files without breaking them: nesting and interpolation, pluralization rules, the anchors and multi-line traps, and automating the round trip.
YAML is the friendliest locale format to read and the easiest one to silently corrupt. It has no schema, no required quoting, and a type resolver that will happily decide your Norwegian locale code is the boolean false. Most teams discover this the first time a translated file comes back and the app boots with half its strings missing.
This is a practical guide to the whole cycle: how Rails, Symfony and the static site generators structure their YAML, how interpolation and pluralization differ between them, the specific YAML features that break round-trips, how to validate a file before it merges, and how to automate the export and import so nobody hand-edits a locale file again.
Where YAML locale files show up
YAML localization is mostly a Ruby and PHP tradition, but it has spread well beyond both.
Rails puts them in config/locales. The convention is config/locales/en.yml, and anything matching config/locales/*.{rb,yml} is loaded automatically. Larger apps split by domain – config/locales/views/checkout/en.yml – and extend I18n.load_path with a glob.
Symfony puts them in translations/, one file per domain and locale: translations/messages.en.yaml, translations/validators.de.yaml. The domain is the part before the locale, and messages is the default.
Static site generators use YAML because everything else in them is YAML. Jekyll keeps translations in _data/locales/en.yml; Hugo uses i18n/en.yaml; Middleman uses locales/en.yml. The structures differ but the traps are identical.
The Rails structure
Rails puts the locale code at the top level of the document, and everything else nests underneath it:
en:
activerecord:
attributes:
user:
email: "Email address"
checkout:
title: "Your order"
button: "Place order"
summary:
subtotal: "Subtotal"
shipping: "Shipping"
Lookup uses dot paths against that tree, so t("checkout.summary.subtotal") resolves to Subtotal. In views a leading dot means lazy lookup – t(".title") inside app/views/checkout/show.html.erb resolves to en.checkout.show.title, which is convenient and makes grep harder. Pick one style per project.
The top-level key is the only thing that changes between a source file and a translation. en.yml starts with en: and de.yml starts with de:; the subtree below must keep identical key paths, or t() returns a translation missing span in production.
Symfony inverts this. The locale lives in the filename, not in the document, so the file starts at the message keys directly:
# translations/messages.en.yaml
checkout:
title: "Your order"
button: "Place order"
That difference matters when you export: a Rails file has a locale-shaped root, a Symfony file has none. A tool that assumes one will mangle the other.
Interpolation
Rails and the i18n gem interpolate with %{name}:
en:
greeting: "Welcome back, %{name}"
cart_note: "%{count} items reserved until %{expires_at}"
Symfony uses %name% by default:
greeting: "Welcome back, %name%"
Two rules follow. First, a translator must carry every placeholder through unchanged, including case – %{Name} is a different variable from %{name}, and Rails raises I18n::MissingInterpolationArgument when a translation references a placeholder the caller did not pass. That is a runtime exception, not a warning, and it is the single most common way a bad translation takes down a page.
Second, a literal percent sign needs escaping: %% in Symfony, and %%{ in Rails for a percent immediately followed by a brace.
Word order is the reason placeholders exist. Never split a sentence into "Welcome back, " plus a name plus "!" – German, Japanese and Latvian all want the pieces in different places, and a translator handed three fragments cannot fix it.
Pluralization
Rails picks a subkey based on the :count argument you pass:
en:
inbox:
unread:
zero: "No unread messages"
one: "One unread message"
other: "%{count} unread messages"
Called as t("inbox.unread", count: 3). The zero key is optional and Rails uses it only when count is exactly 0 and the key exists.
The important part is that the set of keys is not the same in every language. English needs one and other. Russian needs one, few, many and other. Arabic uses all six CLDR categories. Japanese needs only other. A Russian translation of the block above legitimately has four subkeys where English had three, so any check that demands "the same keys as the source" produces false failures on exactly the languages you most need to get right.
Rails ships pluralization rules for English only. For anything else add the rails-i18n gem, which supplies the rule lambdas plus baseline translations. Without it a Russian few branch is never selected and everything falls back to other.
Symfony's modern approach is ICU MessageFormat, enabled by adding +intl-icu to the domain in the filename – translations/messages+intl-icu.ru.yaml:
unread: "{count, plural, =0 {No unread messages} one {# unread message} other {# unread messages}}"
The older pipe-separated syntax still parses, but ICU is what you want for new work: it expresses the same CLDR categories the rest of your stack uses.
The YAML traps that actually break round-trips
Strings that look like booleans
This is the famous one. YAML 1.1 resolves a large set of unquoted scalars to booleans: yes, no, on, off, true, false and their capitalized and uppercase variants. Ruby's Psych and Python's PyYAML both follow that. Core-schema YAML 1.2 parsers such as js-yaml resolve only true and false – so the same file means different things to different tools. This one:
no:
country:
NO: Norge
answer: no
parses in Ruby to a hash keyed by false, containing another key false, whose answer is the boolean false. The Norwegian locale disappears. Bokmål has the code nb and that is what you should use, but Norway's ISO country code really is NO, and country name lists hit this constantly.
The fix is to quote. Always quote every value, and quote any key that is not obviously alphanumeric:
nb:
country:
"NO": "Norge"
answer: "no"
The same resolver eats other things, and the specifics are worth knowing because they are rarely what you would guess. 1.0 becomes a float, so a version string loses its trailing zero. A leading zero means octal, so Ruby loads 0044 as the integer 36 – which is how an international dialling code turns into nonsense. And 12:30 is read as a sexagesimal number rather than a string; Psych returns 45000. You do not need to memorize which value each parser lands on. You need to quote, and then the ambiguity never arises.
Anchors and aliases
YAML lets you define a node once and reuse it:
en:
shared: &buttons
save: "Save"
cancel: "Cancel"
checkout:
<<: *buttons
submit: "Place order"
This is genuinely useful for DRY source files and genuinely hostile to translation. When any tool reads that file it resolves the aliases, so what comes out the other side is the expanded tree – the anchor is gone and save now appears twice. Round-trip the file and your carefully deduplicated source has doubled in size.
Worse, a merge key is not always what a translator wants: Save and Cancel may need different wording in the checkout context than elsewhere, and the anchor hides that decision.
The pragmatic rule: do not use anchors in files that leave your repository. Do reuse at the application layer instead – Rails supports t("checkout.save", default: :"shared.save") – so it is explicit in code rather than encoded in a YAML feature no translation tool preserves.
Multi-line block scalars
Two forms, and the difference matters:
en:
literal: |
Line one.
Line two.
folded: >
This long sentence
is joined with spaces.
literal_chomped: |-
No trailing newline.
The pipe keeps newlines. The greater-than folds them into spaces. The - and + indicators control the trailing newline: |- strips it, |+ keeps all of them, plain | keeps exactly one.
These survive a round trip only if the tool understands them. A naive exporter that reads the value and re-emits it will collapse a folded scalar into one very long quoted line – semantically identical, but the diff is unreadable and the next reviewer will assume something was lost. A worse one drops the chomping indicator and adds a newline to a string rendered inside a <span> tag, and now you have stray whitespace in production.
If the string is genuinely a paragraph, block scalars are correct. If it is a single sentence that just got long, use a quoted scalar and let your editor wrap it.
Whitespace and quoting
Leading and trailing spaces in an unquoted YAML scalar are stripped. If a string legitimately ends with a space – because it is concatenated with something in the UI, which you should avoid but sometimes inherit – the only way to preserve it is to quote:
en:
prefix: "Ordered by "
Prefer double quotes for translated text. Single-quoted YAML has no escape sequences except '' for a literal quote, so \n inside single quotes is a literal backslash-n. Double quotes support the usual escapes and handle apostrophes without doubling – and translated text is full of apostrophes.
Indentation must be spaces. A tab anywhere in the indentation is a parse error, easily introduced when someone edits a locale file in an editor configured for a different language.
Validating after translation
Never merge a translated locale file without three checks.
Does it parse? The cheapest possible gate:
ruby -ryaml -e 'Dir["config/locales/**/*.yml"].each { |f| YAML.load_file(f) }'
Symfony has a built-in linter:
php bin/console lint:yaml translations
php bin/console debug:translation de --only-missing
yamllint works on any project and catches indentation and trailing-whitespace problems that parse fine but signal a bad edit.
Are keys and interpolations consistent? For Rails, i18n-tasks is the tool worth adding to CI:
i18n-tasks missing
i18n-tasks unused
i18n-tasks check-consistent-interpolations
i18n-tasks normalize
check-consistent-interpolations earns its keep – it compares the %{...} placeholders in each translation against the base locale and fails when one was dropped or renamed. normalize rewrites every file with sorted keys and consistent quoting, turning future diffs into real changes instead of formatting noise.
Does the app render? A parse-clean file with a missing key still ships a translation missing string. Make Rails raise instead, in test:
# config/environments/test.rb
config.i18n.raise_on_missing_translations = true
Then any system test that touches a localized page becomes a translation check.
Automating the round trip
The failure mode with YAML is not the first translation – it is the fifth. You add eleven keys and rename two, and someone reconciles that by hand across six files while preserving quoting and block scalars. The loop that works:
- Export the source locale into a system that parses YAML properly, so nesting, quoting and block scalars are understood as structure rather than text.
- Translate only the delta. Keys already approved stay locked; new and changed keys go to a translator or through a machine pass.
- Import back, then run
i18n-tasks normalizeso the repo diff is minimal. - Gate the merge on parse + interpolation consistency + a test run with
raise_on_missing_translations.
Translatize imports and exports YAML alongside JSON (nested or flat), CSV, XLIFF 1.2, gettext PO, Flutter ARB, .NET RESX, Android XML and iOS .strings – nine formats, symmetric in both directions, which matters when the same product strings also power a mobile app. YAML is one of the three that hold every language in a single file; the others are JSON and CSV. Of the rest, six are single-language, and XLIFF 1.2 is bilingual because it pairs each translation with its source text. The value of routing a locale file through something that parses YAML rather than diffing it as text is that a key becomes a record: nesting, quoting and block scalars are structure, not characters a reviewer has to protect by hand on every pass.
Whatever moves the files, the validation job is the part that has to run on every pull request:
- name: Validate locales
run: |
ruby -ryaml -e 'Dir["config/locales/**/*.yml"].each { |f| YAML.load_file(f) }'
bundle exec i18n-tasks missing
bundle exec i18n-tasks check-consistent-interpolations
Before and after
Here is a source file with four of the traps in it:
en:
shared: &actions
save: Save
cancel: Cancel
checkout:
<<: *actions
title: Your order
note: >
Items stay reserved
for 20 minutes.
ship_to: no
items:
one: 1 item
other: %{count} items
In fact that file does not parse at all: % is a reserved indicator in YAML, so an unquoted value starting with it is a syntax error. Fix that and the rest is still wrong – the anchor gets expanded by any tool that touches the file, and ship_to: no is the boolean false. The English plural block has two forms, correct for English and wrong for the Russian file that ships beside it.
Cleaned up, with reuse moved into the application layer:
en:
shared:
save: "Save"
cancel: "Cancel"
checkout:
save: "Save"
cancel: "Cancel"
title: "Your order"
note: >
Items stay reserved
for 20 minutes.
ship_to: "no"
items:
one: "1 item"
other: "%{count} items"
And the Russian file that ships next to it, with four plural forms rather than two:
ru:
shared:
save: "Сохранить"
cancel: "Отмена"
checkout:
save: "Сохранить"
cancel: "Отмена"
title: "Ваш заказ"
note: >
Товары зарезервированы
на 20 минут.
ship_to: "нет"
items:
one: "%{count} товар"
few: "%{count} товара"
many: "%{count} товаров"
other: "%{count} товара"
Note what stayed the same and what did not: every key path matches except the plural subkeys, every %{count} survived, and the block scalar kept its folded form. That is the shape of a correct translation, and the three checks above are what confirm it.
If you are building the broader process around this, the AI localization workflow covers review gating in more depth, and translating JSON files with AI is the closest analogue if part of your stack uses JSON. Laravel developers should start with Laravel localization, since PHP array files behave differently again.
Where Translatize fits
Steps 2 and 4 of the round-trip loop are the ones YAML cannot enforce on its own. Translatize is a translation management platform built around git-like branching: cut a branch for the feature that adds the strings, translate there, merge to main when the feature ships. Merges are permission-guarded – developer and above create branches, only admins and owners merge to main – and choose between overwrite, replace, keep-newer and manual conflict resolution, so a machine pass on a branch cannot quietly replace an approved string on main. Per-label review statuses (draft, review, approved, rejected) are what turn "translate only the delta" into something enforced rather than assumed. AI auto-translation fills missing values for a human to check afterwards, on the Professional and Agency plans. For CI, an mcni_ API token is bound to one project and one branch, so a pipeline cannot write outside the branch it was issued for. @translatize/cli syncs JSON files on disk; the YAML round trip runs through the platform's import and export, as do the other eight formats.
Wrapping up
YAML's flexibility is what makes it pleasant to write and dangerous to automate. Quote every value, keep anchors out of files that leave the repo, be deliberate about block scalars, and remember that plural key sets legitimately differ between languages. Then put a parser check and an interpolation check in CI, so a broken locale file fails on a pull request instead of at 2am in production. Get those in place once and adding a language becomes a file, not a project.
