The translation read beautifully. The meaning was intact. But the number in "Saved 12 wallpapers" had simply vanished.
The wallpaper apps I run on my own carry notification and dialog strings in several languages. Once I started using Gemini for that translation work, the thing that cost me the most time was not translation quality at all. It was format specifiers disappearing without a sound.
When a person proofreads a translation, attention goes to meaning. %1$s and %@ carry no meaning, so the eye slides right past them. You find out on a real device, when the spot where a number belongs is blank.
Anything a machine can catch is better left to a machine. This post walks through building that check.
Why placeholders are the one thing review never catches
Format specifiers occupy an odd position in a translation job. They are part of the sentence, and they are the part that must not be translated.
To the model, %1$s is a short run of symbols with no surrounding context. As it rearranges a sentence into natural word order, that symbol gets swept along. The harder the model works on fluency, the more the placeholder looks like something in the way.
Human review does not save you. Checking "does this read correctly" and checking "are the same symbols still present, in the same count" draw on different kinds of attention. While you are doing the first, the second is switched off.
In my case, the more carefully I read, the fewer I caught. Reading for prose pushes symbols into the background.
Four failure modes, and that is all
After keeping notes for a while, the list turned out to be short.
| Mode | What happens | How it shows at runtime |
|---|---|---|
| Dropped | The specifier disappears from the translation | No value is substituted; some platforms throw |
| Full-width | %@ becomes %@ | No substitution happens; the symbols render as text |
| Duplicated | The same specifier appears twice or more | Argument count no longer matches; undefined reads |
| Reordered | Specifiers move to match target word order | Without positional syntax, values land in the wrong slots |
The fourth one is the awkward case, because reordering is not inherently wrong. If your specifier is positional, like %1$s, the runtime resolves it correctly no matter where it sits in the sentence.
The trouble starts when the source string uses bare %s twice. Swap the order and the values swap too, silently, with no exception raised. A name and a number trade places, and that sentence ships.
One detail worth settling before you automate anything: the two platforms do not spell these the same way. iOS uses %@ for objects and %1$@ when positional, while Android uses %s and %1$s. If you keep both platforms in one translation workflow, decide early whether you normalize to a single internal form and convert on write, or carry both dialects through and let the check handle either. I went with the second, which is why the pattern below accepts both spellings — but the first is a perfectly reasonable choice if your string tables are already in sync.
Which means normalizing your source strings to positional syntax is really step zero. Skip it and the downstream check cannot even decide whether reordering was legitimate.
Stop the breakage at generation time
Before any checking, there are ways to ask that break less often. All of them amount to taking the specifier out of the translation job.
One approach is to swap specifiers for distinctive markers before sending, then restore them afterward. Turning %1$s into something like [[P1]] makes it far more likely the model treats it as an opaque token rather than words to translate.
The other is to receive the result as structured output. Ask for JSON pairing each source string with its translation, and the correspondence is no longer guesswork. Writing the verification step becomes much easier than parsing a long free-form blob. If structured output itself is misbehaving, Fixing Gemini API JSON and structured output failures breaks the symptoms down case by case.
One more note for anyone who has been setting temperature to keep output stable: it is time to plan a different approach, since the sampling parameters are now deprecated. The job of suppressing variation moves from model configuration to your own verification code. Building the check below is part of getting ready for that shift.
A short check to run the moment results come back
The check I actually use does one thing: pull the specifiers out of both strings and compare the counts.
import re
import unicodedata
from collections import Counter
PLACEHOLDER = re.compile(r"%(\d+\$)?[@sdfxu]|%%")
def placeholders(text: str) -> Counter:
# Fold full-width symbols to ASCII before counting
normalized = unicodedata.normalize("NFKC", text)
return Counter(m.group(0) for m in PLACEHOLDER.finditer(normalized))
def has_fullwidth(text: str) -> bool:
# If normalizing changes the result, full-width characters are present
raw = Counter(m.group(0) for m in PLACEHOLDER.finditer(text))
return placeholders(text) != raw
def verify(source: str, target: str) -> list[str]:
src, dst = placeholders(source), placeholders(target)
problems = []
if src - dst:
problems.append(f"dropped: {dict(src - dst)}")
if dst - src:
problems.append(f"duplicated: {dict(dst - src)}")
if has_fullwidth(target):
problems.append("full-width format specifier present")
if src and not all("$" in p for p in src):
# Non-positional specifiers make reordering impossible to validate
problems.append("source uses non-positional specifiers (reordering unverifiable)")
return problemsRunning this against real cases turned up a few things that are not obvious from reading the code.
First, unicodedata.normalize("NFKC", text) converts %@ into %@. So once you normalize, full-width breakage produces no count difference at all. Detecting it requires a separate move: comparing the result with and without normalization. If you trust the count comparison alone, this failure mode walks straight through.
Second, reordering. Counter is a multiset, so order is invisible to it. That is correct behavior for positional specifiers and useless for non-positional ones. The line that inspects whether the source is positional exists for exactly that reason: the check declares the limits of what it can promise.
Third, %% is an escaped percent sign and never a substitution site. Leaving it out of the pattern makes the regex misread a lone % and produce false positives.
Where to put it, and what to do when it fails
I run this check the instant translations come back, before anything is written to a strings file.
I did consider validating at build time instead, and decided against it. Once a broken translation lands in the resource file, it gets harder to tell which lines are generated output and which are hand-written. Putting the boundary right where external content enters the repository keeps that distinction clean.
Lines that fail get held back for that language only. Rather than redoing the batch, I resend just the offending source string. It usually passes on the second attempt. If it fails three times running, I take that as a signal the source sentence is doing too much, and rewrite it shorter.
I deliberately did not automate the retry loop. The fact that one source string keeps failing is itself the useful information. Automate it away and that signal disappears.
Where to start
Open your source strings and look for bare %s and %@. Converting those to positional form is the single change that makes translated reordering safe, and it costs almost nothing.
If you want to go further and score translation quality rather than just structural integrity, Automating localization QA with the Gemini API covers an evaluation pipeline built on structured output. The check in this post protects against broken strings; that one measures whether the translation is any good.
For how the model choice plays out across locales in practice, A month of optimizing App Store keyword fields with Gemini 2.5 Flash has notes from actual operation.
It is a small check. The relief of catching these before a device does turned out to be much larger than the effort.