How does a string catalog (.xcstrings) work, and which runtime files does the build compile it into?
Predict first
What files does a catalog with two languages and a plural compile into?
import Foundation
let fm = FileManager.default
let root = fm.temporaryDirectory.appendingPathComponent(UUID().uuidString)
defer { try? fm.removeItem(at: root) }
try fm.createDirectory(at: root.appendingPathComponent("out"), withIntermediateDirectories: true)
let json = """
{ "sourceLanguage" : "en", "version" : "1.0", "strings" : {
"Hello" : { "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bonjour" } }, "de" : { "stringUnit" : { "state" : "needs_review", "value" : "Hallo" } } } },
"%lld files" : { "localizations" : { "en" : { "variations" : { "plural" : { "one" : { "stringUnit" : { "state" : "translated", "value" : "%lld file" } }, "other" : { "stringUnit" : { "state" : "translated", "value" : "%lld files" } } } } } } },
"Old" : { "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { "state" : "translated", "value" : "Vieux" } } } }
} }
"""
let src = root.appendingPathComponent("Localizable.xcstrings")
try json.write(to: src, atomically: true, encoding: .utf8)
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/xcrun")
p.arguments = ["xcstringstool", "compile", src.path, "-o", root.appendingPathComponent("out").path]
try p.run(); p.waitUntilExit()
let en = fm.enumerator(at: root.appendingPathComponent("out"), includingPropertiesForKeys: nil)!
for case let u as URL in en where !u.hasDirectoryPath {
let rel = u.pathComponents.suffix(2).joined(separator: "/")
let d = NSDictionary(contentsOfFile: u.path) ?? [:]
print(rel, d.allKeys.map { "\($0)" }.sorted())
}
let fr = NSDictionary(contentsOfFile: root.appendingPathComponent("out/fr.lproj/Localizable.strings").path)!
print("fr Old (stale) still compiled:", fr["Old"] as? String ?? "no")
let de = NSDictionary(contentsOfFile: root.appendingPathComponent("out/de.lproj/Localizable.strings").path)!
print("de needs_review compiled:", de["Hello"] as? String ?? "no")
- de.lproj/Localizable.strings ["Hello"] / en.lproj/Localizable.stringsdict ["%lld files"] / fr.lproj/Localizable.strings ["Hello", "Old"] / stale shipped / needs_review shipped
- one file only
- fr has only Hello
Mental model. The catalog is a translators’ spreadsheet; the build prints a separate booklet per language from it. The spreadsheet remembers retired rows, and the printer still prints them until you delete them.
A .xcstrings file is JSON: a sourceLanguage, and a dictionary of keys, each with per-language localizations. A localisation is a stringUnit with a state (new, translated, needs_review, stale flags are tracked per entry) and a value, or a set of variations (plural, device) holding further string units. Xcode keeps the file in step with the source: it adds keys when you build, marks keys that vanished from code as stale instead of deleting them, and shows the state per language so a team can see what is untranslated. It is a source format only; at build time the tool xcstringstool compile turns it into the old runtime files. The check compiles a small catalog and lists the products: one Localizable.strings per language that has plain strings (de and fr) and one Localizable.stringsdict for English, because the key with plural variations became a plural dictionary.
The check also shows two things people assume wrongly. A stale key that still has a translation is still compiled (the French “Old” is shipped), so stale keys cost app size until you clear them, and a needs_review translation is shipped as if it were final (German “Hallo”). Gate releases on the states if that matters (the tool also has print to list keys and sync, extract and generate-symbols commands). Because the output is plain bundle resources, runtime behaviour is the same as the older formats (lookup by key, fallback to the key itself, language matching), and migration from old files is mechanical: Xcode can convert a .strings or .stringsdict into a catalog. Keep comments on keys for translators, split by table for large apps or modules (Q371), and do not translate identifiers.
Plain strings become .strings per language; plural variations become a .stringsdict; stale and needs_review entries are still compiled.
Common traps
- Assuming stale means unshipped. A stale key with a translation is still compiled.
- Shipping
needs_reviewstrings by accident. Gate the release on the state.
They might follow up with
- Can I check coverage in CI? Parse the JSON and fail on
neworneeds_reviewentries; the format is plain JSON. - Does the catalog change runtime behaviour? No, it compiles to the older runtime files.
Related questions
- 2.6.3 How do you localise strings: Localizable.strings, string catalogs, String(localized:)?
- 2.6.4 How do you handle plurals and word order in localised strings?
- 16.5.1 How do resources and localisation work inside a module: Bundle.module, process versus copy, and strings…
- 24.8.2 How do plural categories work across languages, and what does a stringsdict or catalog entry have to contain?
← How do plural categories work across languages, and what does a… What outside your code needs localising: the app name, permission… →