Check a translation's placeholders right in your browser with a free tool, without signup or upload.
Where the % comes from
In an iOS app, a localized string with a variable becomes a format string. In SwiftUI it happens implicitly:
Text("\(count) items left") // key: "%lld items left"
Text("Welcome back, \(name)!") // key: "Welcome back, %@!"
String(localized: "Total: \(price) €") // price is a Double: "Total: %lf €"
An Int gives %lld, a String gives %@, a Double gives %lf. With the older API you write the format yourself: String(format: NSLocalizedString("items_left", comment: ""), count), and the .strings file holds %d or %ld.
Either way, the translator gets text with % signs in the middle.
Three ways to break a translation
Dropping the specifier. "Invitez à modifier cette liste" instead of "Invitez %@ à modifier cette liste". Nothing crashes: the argument is simply ignored and the person's name vanishes from the screen. It's the most common mistake and the quietest.
Changing the type. Replacing %lld with %@, or the other way round. The format then reads the argument as the wrong type: at best a nonsense number, at worst a crash, since %@ expects an object where it gets an integer.
Changing the order silently. "%@ shared %@" turns, in a language with a different word order, into a sentence where the two names are swapped while the syntax stays valid.
Reordering with positions
To change the order, number the arguments: %1$@ is the first argument, %2$@ the second, wherever they sit in the sentence.
/* source */ "%@ shared %@ with you" = "%1$@ shared %2$@ with you";
/* French */ "%@ shared %@ with you" = "%1$@ vous a partagé %2$@";
/* other order */ "%@ shared %@ with you" = "%2$@ : partagé par %1$@";
Within a string, either everything is numbered or nothing is. Mixing %1$@ and %@ is undefined behavior.
A French trap: "20 % de remise"
French typography puts a space before the percent sign: "20 % de remise" (20% off). In a format string that's a problem, because % d (percent, space, d) is a valid specifier: the space is a printf flag ("pad positive numbers with a space") and d the integer conversion. The sentence
"Profitez de %lld % de remise"
therefore holds two specifiers, %lld and % d, for a single argument. The second one reads a value that doesn't exist. The correct form doubles the sign:
"Profitez de %lld %% de remise"
It only affects strings that go through a format, meaning strings with at least one variable. A fixed "20 % de remise" with no argument displays as written. But translations evolve: the day someone adds a variable to that sentence, the lone % becomes a bug.
Plurals
For "1 item / 3 items", don't write two keys and an if in your code. In a String Catalog, the %lld items left key gets plural variations (one, other, plus few or many depending on the language), each with its own %lld. When a sentence has two numbers that vary independently, Xcode uses substitutions (%#@items@): a rarer case, edited in Xcode.
Checking before you ship
The basic check is mechanical: for each translation, the list of specifiers (type and position) must match the source. That's what LocalePilot's QA does on .strings and .xcstrings files: it recognizes %@, %1$@, %d, %ld, %lld, %.2f, %u, %i and %s, treats %% as text, and flags a missing or extra specifier before export. It deliberately ignores rare conversions such as %e or %x so that "20%en" isn't taken for an error. The "% d" case above is one for human review: search your French translations for a % followed by a space.