A placeholder is the part of a translatable string that is not translatable. Every framework invented its own notation for it, none of them agreed, and a typical product ends up carrying three or four syntaxes at once — one from the mobile app, one from the web front end, one from the backend, one from an email template.
This page is the reference table for all of them, followed by the rules that keep placeholders intact through translation.
The cheat sheet
| Syntax | Name | Where you will meet it | Example |
|---|---|---|---|
%s %d %f |
printf conversion specifiers | C, gettext, PHP, Python, Java, Go, Android, Ruby | Welcome back, %s |
%1$s %2$d |
printf, positional by index | Android (required with 2+ args), Java, PHP, gettext | %1$s added %2$s |
%@ |
Objective-C object specifier | iOS, macOS .strings files |
Hello, %@ |
%1$@ |
Objective-C, positional | iOS with multiple arguments | %1$@ invited %2$@ |
%{name} |
Ruby named interpolation | Rails I18n, Ruby YAML files | Hello, %{name} |
%<count>d |
Ruby named, with a format spec | Rails I18n when the value needs formatting | %<count>05d |
%(name)s |
Python named printf | Python %-formatting, Django |
Hello, %(name)s |
{name} |
Python str.format, .NET, ICU, Java |
str.format, f-strings, ICU MessageFormat |
Hello, {name} |
{0} {1} |
Positional by index | Java MessageFormat, .NET, C# |
{0} invited {1} |
{0,number,integer} |
Java MessageFormat with a format type |
Java resource bundles | {0,number,currency} |
{{name}} |
Mustache-style interpolation | Handlebars, Mustache, Angular, i18next, Vue | Hello, {{name}} |
{{{name}}} |
Mustache, unescaped | Handlebars/Mustache raw HTML output | {{{html_body}}} |
%1 %2 |
Qt argument markers | Qt Linguist .ts files |
%1 of %2 files |
$1 $2 |
Regex-style backreference | Some JS libraries, Chrome extension messages | Hello $1 |
<xliff:g id="name">%s</xliff:g> |
Android placeholder annotation | strings.xml |
wraps a %s with a name and example |
# |
ICU plural value | Inside an ICU plural branch only |
{n, plural, other {# files}} |
$t(key) |
i18next nesting | i18next | $t(common.appName) |
${name} |
Template literal / shell-style | JS template strings, some YAML tooling | `Hello ${name}` |
printf, in a bit more detail
The printf family is by far the most common, and the letter is not decoration — it declares a type, and the runtime will fail if the argument does not match:
| Specifier | Type | Notes |
|---|---|---|
%s |
string | In Objective-C this means a C string; use %@ for an NSString |
%d %i |
signed integer | |
%u |
unsigned integer | |
%f |
float | %.2f fixes two decimals |
%x %X |
hexadecimal | |
%% |
a literal percent sign | The escape that gets forgotten most often |
%@ |
Objective-C object | description is called on it |
%% deserves its own line. "50% off" in a printf-parsed string is a bug: the % is read as the start of a specifier. It has to be "50%% off".
What actually breaks
Reordering. English says “%s added %s to the project”. German, Japanese and Turkish may need those nouns in the other order. With bare %s there is no way to express that — the arguments are consumed in order of appearance. The numbered form %1$s fixes it, and named placeholders fix it better.
Retyping. Translators work in a text field. A placeholder is a run of punctuation with no meaning in their language, and it gets retyped, autocorrected, smart-quoted, or translated. %{count} becomes %{compte}. {{name}} becomes {{nom}}. Both look fine and neither substitutes.
Whitespace and case. %{ name } is not %{name} in Ruby. {{Name}} is not {{name}} in Handlebars. Nothing warns you.
Type mismatch. Swap %d and %s in a Java format string and you get an IllegalFormatConversionException at runtime. Do it in Objective-C and you get undefined behaviour reading past the argument.
Concatenation. The subtlest one, and it is a source-side bug rather than a translation bug: building a sentence from a translated fragment plus a formatted value assumes the fragment order holds in every language. It does not. Put the whole sentence in one string with a placeholder in it.
Rules that keep placeholders alive
- Name them wherever the format allows.
%{recipient}tells the translator what will appear there;%stells them nothing and{0}tells them less. Named placeholders also survive reordering for free. - Never split a sentence across strings. One sentence, one segment, placeholders inside it.
- Give context for the value. “%d” could be a count, a year, or a percentage, and the surrounding grammar differs for each. A developer comment on the segment costs seconds and prevents a whole class of mistranslation.
- Use the positional form as soon as there are two arguments, even if the source language does not need it. It is the only thing that makes reordering possible downstream.
- Validate mechanically, in the editor. Comparing the placeholder set of the source against the target is a cheap, exact check — and it is the only one that reliably catches a translated placeholder, because a human reading for meaning sees a correct French word rather than a broken variable.
One string, many syntaxes
The same sentence across a real cross-platform product:
Android strings.xml %1$s shared %2$d files
iOS .strings %1$@ shared %2$ld files
Rails en.yml %{user} shared %{count} files
React i18next JSON {{user}} shared {{count}} files
Java .properties {0} shared {1} files
ICU any {user} shared {count, plural, one {# file} other {# files}}
Six spellings, one sentence. For a translator these are six unrelated jobs, and for a translation memory built on exact string matching they are six unrelated entries — the Japanese translation done for Android is invisible when the same sentence comes up in the Rails file.
This is the problem WebTranslateIt’s normalized translation memory solves. Before comparison, placeholders are stripped and HTML entities decoded, so all six variants above normalise to the same text and match each other. When a match is reused, the placeholders are adapted back into the syntax the destination project uses — a Rails %{user} becomes an iOS %1$@ on the way in. Normalized matches are labelled as such and rank below exact matches, so an exact hit always wins; the feature needs no configuration and existing translation memory was backfilled into the index.
Stopping placeholders being broken in the first place
Reuse is half the problem. The other half is that a placeholder survives the round trip at all — and that is a job for validation at the moment of translation, not a build step afterwards.
WebTranslateIt validates every translation as it is saved, comparing the placeholder set in the target against the source. It flags four distinct failures:
- The source has no placeholder but the translation introduced one.
- The source has a placeholder and the translation has none.
- The translation contains placeholders that are not in the source — the signature of a translated placeholder name,
%{name}becoming%{nom}. - A placeholder is wrong in one form of a plural segment, which is easy to miss because the other forms look fine.
It recognises the syntaxes on this page rather than a single house style — the printf family including positional and padded forms (%s, %d, %1$s, %02d, %,d), Objective-C %@, Ruby %{variable}, single and double brace forms, ERB tags (<%= @variable %>), i18next nesting ($t(...)), and delimiter-based conventions like @variable@ and {!1}. That breadth is the point: a validator that only knows one syntax is useless on a codebase carrying four.
When something fails, the editor does not just refuse the save. It shows the translation with each problem highlighted in its own colour, alongside a list of the issues; hovering an issue highlights the corresponding text and vice versa, so the translator can see exactly which character sequence is wrong rather than being told the string is invalid. That distinction matters when the difference between right and wrong is %{name} versus %{nom}.
From there the translator gets three options: correct it manually, ignore the warning when they are confident it is a false positive, or AutoCorrect — which applies deterministic repairs first, restoring mangled placeholders to the form the source uses, and falls back to AI for the issues needing contextual judgement.
The reason to do this in the editor rather than in CI is that the person who can fix a mangled placeholder correctly is the translator, and they are only available for a few seconds. A build failure two days later means reopening the task, explaining the problem across a language barrier, and waiting — for an error with exactly one correct answer.
Quick reference by ecosystem
| Platform | Primary syntax | Also seen |
|---|---|---|
| Rails / Ruby | %{name} |
%<name>s, %s |
| Django / Python | %(name)s |
{name}, %s |
| Laravel / PHP | :name |
%s, %1$s |
| Symfony / PHP | %name% |
ICU via the intl-icu domain |
| Java | {0} |
%s, %1$s |
| .NET / C# | {0} |
{name} in interpolated strings |
| Android | %1$s |
<xliff:g> wrappers |
| iOS / Swift | %@, %1$@ |
%d, %ld |
| React (i18next) | {{name}} |
$t(key) for nesting |
| React (FormatJS) | {name} |
full ICU MessageFormat |
| Vue | {name} |
{{ }} in templates, @:key linking |
| Angular | {{name}} |
ICU in $localize messages |
| Go | %s, %v |
{{.Name}} in templates |
| Flutter | {name} |
full ICU via intl |
When a project spans several rows of that table — and most do — the placeholders are not a detail. They are the thing most likely to be silently wrong in the language you cannot read.
Frequently asked questions
- What does %s mean in a translation file?
- %s is a printf conversion specifier meaning “substitute a string here”. It comes from C and appears in gettext, Java, Python, PHP, Android and Go. The letter chooses the type: %s string, %d integer, %f float. It is positional — the first %s takes the first argument — which is why translations that need a different word order use the numbered form %1$s instead.
- What is the difference between {name} and {{name}}?
- Single braces are ICU MessageFormat, Java MessageFormat, Python str.format and .NET. Double braces are Mustache, Handlebars, Angular interpolation and i18next. They are unrelated syntaxes that happen to look similar, so a value copied from one system into another will render as literal text rather than substituting.
- Why does %1$s appear instead of %s?
- The number makes the placeholder positional by index rather than by order of appearance, so a translation can reorder the arguments. Languages differ in word order, and a sentence like “%s added %s to the project” may need its two nouns swapped. Without the index there is no way to express that. Android requires the numbered form whenever a string has more than one argument.
- What happens if a translator deletes a placeholder?
- It depends on the runtime: a missing argument may render as blank, print the raw placeholder, or crash with a format exception. Java and Objective-C are the harshest — a mismatched format specifier in Objective-C reads adjacent memory. This is why placeholder validation belongs in the translation tool, where the person who can fix it is still looking at the string.
Keep reading
-
ICU MessageFormat: a practical guide
The syntax for plurals, gender selection, number and date formatting in one message string — with the escaping rules and the mistakes translators reliably make.
-
Plural rules by language: the complete CLDR guide
A reference table of CLDR plural categories for 163 languages, plus why one does not mean 1 and how each i18n framework expects you to spell the rules.
-
Placeholder validations in WebTranslateIt (documentation)
How WebTranslateIt highlights placeholders and refuses translations that dropped or mangled one.
Translate your app without the spreadsheet round-trip
WebTranslateIt reads the file formats and placeholder syntax described on this page, validates them as translators work, and syncs the results straight back into your repository.