Gentian AsaniIf you maintain a multilingual iOS app, you've probably looked at String Catalogs (.xcstrings) at...
If you maintain a multilingual iOS app, you've probably looked at String Catalogs (.xcstrings) at least twice and put off the migration both times. They're a real improvement on Localizable.strings plus Localizable.stringsdict: one file per resource, declarative plurals, type-safe lookups via String(localized:), device-class variations. But the migration is messier than the Apple sample projects let on, and the part that bites hardest is the bit Apple's documentation barely covers: keeping your translator pipeline working through the transition.
This guide covers the part Apple skipped. It's written for iOS teams shipping to five or more App Store regions who use external translators and exchange XLIFF files with them. If you have one translator (or you're the translator), some of this still applies but the round-trip risk is lower.
A Localizable.strings file is just key-value lines, one locale per file, with comments above each entry. Plurals live in a sibling Localizable.stringsdict file with verbose XML.
A Localizable.xcstrings file is one JSON document covering all locales. It has dedicated fields for plural variants, device variants, comments, extraction state, and per-locale translation state.
That last part is the one that matters during migration. In .strings files, a string is either present or absent. In .xcstrings, every key for every locale has an explicit state: new, translated, needs_review, stale, or absent. The state model is closer to how teams actually ship (some keys translated, some not), but it also means migration tools have to make a choice for every existing translation: is it translated, or is it stale because it predates a source change?
Xcode's auto-migration assumes everything that was in your existing .strings files is translated. That's optimistic.
If you have a translator pipeline, the format you ship them is XLIFF. Xcode 16.4 emits XLIFF version 1.2. The root element of an export looks like: <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">. Xcode generates XLIFF from String Catalogs the same way it does from .strings, via xcodebuild -exportLocalizations. The output is an .xcloc bundle per target locale, each containing a Localized Contents/<lang>.xliff file plus a Source Contents/ folder for screenshots and a Notes/ folder for translator notes. Most CAT tools accept the .xcloc directly; some only want the inner .xliff. Translator receives XLIFF, works in their CAT tool (Trados, memoQ, OmegaT) or TMS (Lokalise, Phrase, Crowdin), and returns XLIFF. You then xcodebuild -importLocalizations and the changes land in your catalog.
The round-trip looks clean in theory. In practice, five things go wrong during the migration window:
.strings, .xcstrings, and XLIFF.I'll cover each. If you miss them, you find out in a 1-star Polish review.
English has two plural categories: one and other. "1 item" versus "2 items". .stringsdict files with English source usually only define these two, and .xcstrings inherits the same coverage.
If you have a translator pipeline, the format you ship them is XLIFF. Xcode 16.4 emits XLIFF version 1.2. The root element of an export looks like: <xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">. Xcode generates XLIFF from String Catalogs the same way it does from .strings, via xcodebuild -exportLocalizations. The output is an .xcloc bundle per target locale, each containing a Localized Contents/<lang>.xliff file plus a Source Contents/ folder for screenshots and a Notes/ folder for translator notes.
Most CAT tools accept the .xcloc directly; some only want the inner .xliff. Translator receives XLIFF, works in their CAT tool (Trados, memoQ, OmegaT) or TMS (Lokalise, Phrase, Crowdin), and returns XLIFF. You then xcodebuild -importLocalizations and the changes land in your catalog.
The round-trip looks clean in theory. In practice, five things go wrong during the migration window:
.strings, .xcstrings, and XLIFF.I'll cover each. If you miss them, you find out in a 1-star Polish review.
English has two plural categories: one and other. "1 item" versus "2 items". .stringsdict files with English source usually only define these two, and .xcstrings inherits the same coverage.
Polish has four: one, few, many, other. The rough mapping is "1 item" (one), "2-4 items" (few), "5-20 items" (many), "1.5 items" (other), but the actual rule is modulo-based, so 21 reverts to one, 22 to few, and so on. Russian has the same four categories as Polish. Arabic has all six (zero, one, two, few, many, other). Welsh and Irish each have their own variants. If your source only specified one and other, the migration carries the same gap into String Catalogs, and your Polish translator either fills in the missing categories (few, many) or returns XLIFF that's missing them.
If the XLIFF is missing them, your app will hit one for "1 item" and other for everything else, which is grammatically wrong in Polish for most cases. Apple's runtime quietly falls back. The user sees "5 elementów" stylized as "5 elementem". Your testers won't catch it unless they're Polish speakers checking edge counts.
What to do: before sending the first post-migration XLIFF to a Polish, Russian, or Arabic translator, audit the source's plural coverage against the CLDR plural rules for that locale. The Unicode CLDR project publishes them at https://cldr.unicode.org/. The minimum coverage for each language is a documented contract, not a suggestion.
English has two plural categories: one and other. "1 item" versus "2 items". .stringsdict files with English source usually only define these two, and .xcstrings inherits the same coverage.
Polish has four: one, few, many, other. The rough mapping is "1 item" (one), "2-4 items" (few), "5-20 items" (many), "1.5 items" (other), but the actual rule is modulo-based, so 21 reverts to one, 22 to few, and so on. Russian has the same four categories as Polish. Arabic has all six (zero, one, two, few, many, other). Welsh and Irish each have their own variants. If your source only specified one and other, the migration carries the same gap into String Catalogs, and your Polish translator either fills in the missing categories (few, many) or returns XLIFF that's missing them.
.strings files store characters mostly as-is. Hello, "world"! works. Apostrophes and quotes pass through.
.xcstrings files are JSON. They escape with backslash-u, and they accept Unicode literally for most ranges. Smart quotes survive.
XLIFF files are XML. They require entity encoding for ampersand, less-than, and greater-than, and most translation tools auto-escape on export. Your translator's CAT tool might emit &quot; instead of a literal quote, or it might leave a literal < that breaks the XLIFF parser on re-import.
A naive round-trip during the migration window can corrupt strings in ways you won't catch without a diff. The string "AT&T" in source might come back as "AT&T" in some locales and "AT&T" in others depending on whose tool ran double-escaping. Both will display incorrectly to users.
What to do: after re-importing translator XLIFF into the catalog, scan every target string for the four common corruption patterns: &amp;, &lt;, &gt;, and literal < or > in non-HTML contexts. A 20-line Python or Node script catches the entire class.
Comments in .strings look like this above each line: /* Title for the welcome screen */. They become the comment field in .xcstrings. They become <note> elements in XLIFF.
The mapping is supposed to be lossless. It often isn't. Xcode's XLIFF exporter writes plain <note>Comment text</note> elements with no from attribute, and emits <note/> for keys without a comment. Some translator CAT tools strip <note> elements on save, treating them as their own internal metadata. Others rewrite them with a from="translator" attribute that Xcode then ignores on re-import. Re-importing without manual reconciliation loses the developer context.
Here's how that plays out: the next time the translator opens that key, they see "Welcome" with no context, and they translate it as a noun. The original meaning was the verb (an action your app is performing). Now your German users see "Willkommen" where you wanted "Heißen wir willkommen" because the comment that would have disambiguated this was lost two round trips ago.
What to do: keep a separate source-of-truth for translator notes (a sidecar JSON or YAML file mapping key to comment), and validate after every round-trip that the comment field in .xcstrings matches the canonical source. This is the kind of thing that should be a CI check, and isn't, in any tool I know of.
When Xcode emits XLIFF from a String Catalog, a key that exists in the catalog with no target translation for German shows up as a <trans-unit> with an empty <target> and (depending on Xcode version) a state of new or no state at all.
A translator opening that XLIFF in their tool sees a list of segments. Tools generally show "new" segments first. But "no state" segments may sort with existing translations, depending on the tool. The translator picks up only the explicitly-new segments and skips the no-state ones, assuming someone else already handled them.
The result: keys that look translated to the translator come back untranslated in the next import. You think you sent 50 new strings; the translator thinks they handled 30 new strings; 20 mysterious empties show up in your re-imported catalog and nobody knows why.
What to do: before sending XLIFF to translators, scan for every <trans-unit> with an empty <target> and stamp it with state="needs-translation" if it isn't already stamped. This is a one-line xmllint operation but, again, not built into Xcode or any major translator tool by default.
.strings files use %@, %d, %lld, and friends. .xcstrings files inherit them, and the Swift compiler enforces them at the call site via String(localized:defaultValue:table:bundle:locale:comment:) and friends.
What breaks: the order of tokens in the translated string. English "You have %d items in %@" might be German "Sie haben %d Artikel in %@" (same order) or French "Vous avez %d articles dans %@" (same order) or Japanese "%@に%dアイテムあります" (reversed order, requires %2$@ and %1$d positional tokens).
If your translator returns Japanese without positional tokens, Swift's runtime fills the placeholders in source order, and your Japanese users see something like "5にカートアイテムあります" instead of "カートに5アイテムあります". The grammar is wrong and the variables are in the wrong place.
What to do: validate that the count and types of format specifiers in every target string match the source. If source has one %@ and one %d, target must have exactly one of each, and if positional tokens are used, every position must be present. The Swift compiler does some of this at compile time for keys it can statically resolve, but not for runtime-loaded strings or for non-default tables.
The one build setting that breaks the migration if you skip it. In your project's Build Settings, search for "Use Compiler to Extract Swift Strings" and set it to YES. This enables Xcode's automatic extraction of localizable strings into the catalog from your Swift source. If you skip this, the auto-migration still works on existing .strings keys, but any new String(localized:) call you add afterward will never make it into the catalog. You only find out months later when the Polish version is missing half your strings. This is the single most common silent failure I've seen during migrations and it's invisible until it bites.
Snapshot what you have. Commit the current .strings, .stringsdict, and your last .xcloc bundle from xcodebuild -exportLocalizations to version control. The .xcloc is the bundle Xcode hands to translators: it contains the XLIFF plus screenshots and notes. If you've never exported before, do it now so you have a pre-migration baseline.
Decide on extraction mode. Xcode supports automatic extraction (compiler pulls strings from Swift source) and manual (you maintain the catalog by hand). Most teams use automatic. If you have a framework target or Swift package with localizable strings, you also need the #bundle macro (Xcode 26 and later) or Bundle.module for those callers. The migration plan below assumes the app target plus automatic extraction.
Step 0: With the prerequisites in place, snapshot the current .strings files plus the last XLIFF export sent to translators. Both go in version control.
Step 1: Let Xcode auto-migrate. Right-click Localizable.strings in Xcode, "Migrate to String Catalog." Xcode generates Localizable.xcstrings and pulls in existing translations. Don't delete the old .strings files yet. If you have a separate InfoPlist.strings, it gets its own InfoPlist.xcstrings and the same migration steps apply.
Step 2: Validate the auto-migration with a per-locale per-key diff. For every key in the old Localizable.strings for every locale, confirm the same source-target pair exists in Localizable.xcstrings. A 30-line script does this.
Step 3: Re-export XLIFF from the new String Catalog. Diff against the last XLIFF you sent translators. Expected differences:
state="needs-translation" for previously-untranslated entriesIf existing translations changed, you have a migration bug. Stop and investigate before sending anything to translators.
Step 4: Audit plural coverage per target locale against CLDR requirements. Fill any gaps in source before the first translator round-trip on the new format.
Step 5: Audit translator notes survived the migration. Compare comment fields in .xcstrings against your canonical source.
Step 6: Send the new XLIFF to translators with a note that the format is now String Catalogs and that any state confusion should be reported back. Translators are human, tell them what changed.
Step 7: After the first clean round-trip, you can delete the old .strings files.
If you're shipping multilingual iOS apps and migrating to String Catalogs, here's the CI checklist worth automating:
needs-translation state.&amp;, &lt;, etc.).Localizable.xcstrings parses as valid JSON without manual edits.Most iOS teams handle two or three of these in ad-hoc scripts. None ship them as a unified CI step. App Review won't flag any of these. App Store users in non-English locales will, in 1-star reviews with screenshots.
The TMS platforms in this space (Lokalise, Phrase, Crowdin) all support .xcstrings import/export and have CI integrations of varying depth. What none of them ships out of the box is a single validation step that catches missing translations, broken CLDR plural coverage, placeholder count mismatches, and migration-drift between legacy and catalog formats, all in one tool you can drop into an existing GitHub Action. Most teams I've seen handle two or three of these in ad-hoc scripts.
I'm building one. It's called LocaleLint, the free CLI plus GitHub Action are at https://localelint.gen-a.dev. If you're mid-migration and want updates as it matures, sign up there. If you've already migrated and got bitten by one of the issues above, I'd love to hear what bit you most: open an issue at https://github.com/YinsPeace/localelint or comment on this post.
.strings files until you've validated parity for every key in every locale.Your future German, Polish, Arabic, and Japanese users won't write you a thank-you note. They also won't write you a 1-star review, which is what you're actually buying here.
Senior full-stack engineer based in Sweden, building indie dev tools at gen-a.dev. LocaleLint is at localelint.gen-a.dev and on GitHub.