All articles
10 min read

Translating Android strings.xml Without Breaking the Build

How Android resource qualifiers, escaping rules, format args and plurals actually work, and the mistakes that turn a translation update into a broken build.

AndroidMobileLocalization

The German translation lands the night before a release, someone copies it into values-de/strings.xml, and the build fails on a line that has nothing to do with German grammar. An unescaped apostrophe, usually. Or a resource name with a dot in it, carried over from the web locale file someone pasted from. Android's string resources look like plain XML, and mostly behave like it too, until one of a handful of rules you've never had reason to learn trips a build error with a stack trace that points at the wrong file entirely.

This is a tour of the rules that actually matter: where translated strings live, what a resource name is allowed to be, the escaping that catches almost everyone once, format arguments, plurals, and the one mistake nearly every team makes with them at least once.

Where translated strings live

A default Android project keeps its base strings in res/values/strings.xml. A translation for German goes in res/values-de/strings.xml – same file name, different folder, and Android's resource system picks the right one at runtime based on the device's locale, falling back to the default values/ folder when no qualified match exists.

The qualifier after values- isn't just a two-letter language code. It follows Android's resource qualifier rules, and two forms are worth knowing beyond the simple case:

  • Region-qualified: values-zh-rTW for Traditional Chinese as used in Taiwan – the r prefix marks a region code (ISO 3166-1), so this reads as "Chinese, region Taiwan."
  • Script-qualified (BCP 47): values-b+sr+Latn for Serbian written in Latin script rather than Cyrillic. The b+ prefix switches Android into full BCP 47 tag parsing, which is how you reach qualifiers that the short language-rREGION form can't express.

Get the folder name wrong – a typo in the qualifier, a region code that doesn't match any real locale – and Android doesn't error. It just never selects that folder, and the device quietly falls back to the default strings. That's a worse failure than a build break, because nothing tells you it happened; you only notice when someone in Taiwan files a bug that the app is in the wrong language.

Resource names are not your i18n keys

An Android string name has to be a valid resource identifier, which means no dots. If your team already has a web app with dotted keys – checkout.button.submit, nav.profile.edit – you cannot paste those names directly into strings.xml. The convention that's stuck is to convert dots to underscores: checkout_button_submit. It's lossy in the sense that you can't mechanically tell where the original hierarchy was just from reading the flat name, but it's a one-rule conversion that's easy to apply consistently and just as easy to reverse.

A resource name also has to start with a letter, not a digit, and can't collide with another name in the same file – Android generates a Java/Kotlin field (R.string.checkout_button_submit) for every one of them, so the usual identifier rules apply, just as they do for Flutter's ARB keys.

Escaping rules that bite

This is the section worth bookmarking, because the failure modes are small and the symptoms are not obviously about escaping.

  • Apostrophes. Android's string resource parser treats a bare apostrophe as special. Write Don't unescaped inside <string> and you'll get a build error, not a runtime one. Fix it with a backslash (Don\'t) or wrap the whole value in double quotes ("Don't").
  • Double quotes. The reverse problem: a literal " inside the value needs a backslash (\"), since an unescaped one looks like the end of an attribute-style value to the parser in some contexts.
  • Leading @ and ?. A value that starts with @ is interpreted as a reference to another resource (@string/other_key), and one starting with ? as a theme attribute reference. A translated string that happens to start with either character – a handle like @support in a label, or a value that genuinely opens with a question mark – needs an escaping backslash (\@, \?) or it will resolve as a reference instead of literal text.
  • & and <. Because the file is XML underneath, these need the standard entities, &amp; and &lt;. A translator pasting "Legal & Compliance" straight out of a document is the single most common way an unescaped ampersand ends up in a diff, and it breaks the whole file for the parser, not just that one string.

HTML styling and CDATA

Android strings can carry basic styling tags – <b>, <i>, <u> – directly in the resource. Fetch the value with getText() and you get a CharSequence with the styling applied; getString() returns a plain String and quietly drops it. For richer HTML, or for a styled string that also needs String.format() arguments, the documented route is different: escape the markup (&lt;b&gt;) or wrap the value in a CDATA section so the resource compiler leaves it alone, format it, then render it with Html.fromHtml(). Translators need to know which of the two a given string uses, because the escaped form looks like noise and is very tempting to "clean up".

Format arguments

%1$s, %2$d and friends are positional printf-style format specifiers – the number before the $ says which argument fills that slot, and the letter after it says the type (s for string, d for integer, f for float). The positional form matters more than it looks like it should: if a string uses plain %s %s with two arguments and nothing else, Android's build tooling will actually flag it, because without positions a translator has no way to reorder the arguments, and many languages need to. "You have 3 new messages from Anna" might need to become "Anna has sent you 3 new messages" in another language, and that's only possible to express safely if the format string uses %1$s and %2$d rather than relying on argument order matching English word order.

Translators must preserve every format specifier exactly – same type character, same count – and may reorder the %N$ positions but never invent or drop one. That's exactly the same invariant JSON and ARB placeholders need, just with a different syntax.

Translatize checks this for you. Its placeholder check knows printf-style specifiers, positional ones included, so a German value that lost its %2$d or picked up a stray %s is flagged next to the string in the editor, long before it reaches a build. AI translation holds itself to the same rule: a value whose specifiers don't match the source is left empty for a person rather than written.

translatable="false"

Not every string in strings.xml is UI copy. Font family names, internal analytics event names, API endpoint keys stored as string resources for convenience – anything that happens to live in strings.xml but was never meant to be translated gets marked:

<string name="font_family_primary" translatable="false">Inter</string>

Tooling that walks the file for translation work should respect this flag and skip the entry, and most locale-generation tools do. It's worth marking deliberately rather than relying on a translator to notice context and leave something alone.

Translatize has the same idea on the key itself: a "Do not translate" label. The source text is copied into every language instead of being translated, it is never sent to AI translation or billed, and the quality checks accept the copy rather than flagging it as untranslated – which suits product names and codes that live in the shared key set rather than only in strings.xml.

Plurals, and the mistake almost everyone makes once

Android's plurals resource maps directly onto CLDR's plural categories – zero, one, two, few, many, other – the same set every serious pluralization system uses, because the actual linguistic rules don't change based on which platform is reading them:

<plurals name="unread_messages">
    <item quantity="one">%d unread message</item>
    <item quantity="other">%d unread messages</item>
</plurals>

Here's the mistake. A developer writing the English source wants to special-case zero – "No unread messages" instead of "0 unread messages" – and reaches for <item quantity="zero">. It compiles fine, and it does nothing: English's plural rules only ever resolve a count to one or other, never to zero, so an English-locale plurals resource with a zero item has dead code sitting in it. The number 0 resolves to the other branch in English, same as 2 or 200. If you need a dedicated message for zero, that has to be an explicit if (count == 0) check in code before you ever reach the plurals resource – the CLDR zero category is real and load-bearing in languages like Arabic and Latvian, just not in English, and Android will not tell you your English zero branch is unreachable.

The inverse mistake happens on the way out: a language that genuinely needs few and many as distinct from other – Polish and Russian both do – will silently fall back to other for every form you didn't provide, with no error. Polish needs four forms where English needs two; a translation delivered with only one/other isn't wrong exactly, it's just missing two categories your source language never needed you to think about.

string-array

For a small fixed list – weekday names, a set of unit options – string-array keeps the items together and accessible by index:

<string-array name="weekday_short_names">
    <item>Mon</item>
    <item>Tue</item>
    <item>Wed</item>
</string-array>

The index is the only thing tying an entry to its meaning, so order must match exactly across every locale file – a translated array with items reordered, or one entry dropped, shifts every subsequent index and produces a wrong-but-valid-looking string at runtime rather than a crash. There's no built-in safety net here the way there is for a named <string> key; a code review step that checks array lengths and rough content per locale is worth having if you rely on these for anything user-facing.

Keeping one key set across platforms

WhereFormatThe same key, as written
Translatizecanonicalcheckout.button.submit
Web appJSONcheckout.button.submit, or nested under checkout → button
Androidstrings.xmlcheckout_button_submit
iOS.strings"checkout.button.submit"
FlutterARBcheckout_button_submit

The underscore-for-dot convention described above isn't just an Android quirk to work around once and forget – it's exactly what lets one project hold the keys for a web app and an Android app at the same time. A key stored as checkout.button.submit comes out of an Android export as checkout_button_submit automatically, and a strings.xml file imported the other way converts the underscores back to dots on the way in. You don't maintain a separate mapping table; the same conversion rule that Flutter's ARB format needs applies here, so a mobile and a web client can share one source of truth without either side seeing the other's key convention.

Exporting a branch as Android XML for a given locale, through the app or directly against the token-authenticated API:

curl -H "Authorization: Bearer $TRANSLATIZE_API_TOKEN" \
  "https://api.translatize.com/v1/integrations/projects/$PROJECT_ID/export?format=android&lang=de&branch=feature-onboarding" \
  -o values-de/strings.xml

Importing a translated file back works the same way in reverse, through the app's import screen, and the round trip is symmetric – the file you'd export for a given language is also the shape it expects coming in. If your team keeps the canonical source strings in JSON on disk alongside the Android resources, the CLI is still useful for that half of the job: npx @translatize/cli push uploads new and changed source keys, and npx @translatize/cli status --fail-on-missing is the gate that fails a pull request when a target language falls behind. The CLI itself works against JSON files; the Android strings.xml round trip for an individual locale comes from the app or the API directly, which is where you'd be reaching for it anyway once a translation is ready to hand to a translator or bring back in.

One project covering both platforms also means the branching model applies the same way it would for any other format: a feature branch carries the new onboarding strings, a translator or an AI pass fills them in on that branch, any project member can submit a translation for review, a developer, admin or owner approves it, and nothing reaches the Android build's values-de/ folder until an admin or owner merges the branch into main.

For the plural and format-argument mechanics in more general terms, including a worked multi-language example, the ICU message format guide covers the same CLDR categories from the angle of a format designed around them from the start rather than retrofitted onto Android's plurals XML. If you're maintaining the same string catalogue for a Flutter client alongside Android, translating Flutter ARB files covers the equivalent dot-to-underscore conversion on that side.

Where to start

If you're adding your first non-English values-xx/ folder this week: get the resource names right before anything else (no dots, valid identifiers), mark anything non-translatable, and read every value your translator returns for a bare & or an unescaped apostrophe before it goes anywhere near a build. Everything else in this list is a slower, quieter failure mode – worth knowing about, but not the thing that will wake you up the night before a release.

Keep reading