Most placeholder syntaxes only substitute: you hand them a value and they drop it into a slot. ICU MessageFormat does something different — it lets the structure of the sentence depend on the value. One string, and the plural form, the gendered pronoun and the number formatting all resolve at render time, in whatever the target language needs.
That power is the reason it exists and the reason it is easy to get wrong. This guide covers the syntax you will actually use, the escaping rule that trips everyone, and the specific mistakes that survive review and reach production.
The shape of a message
At its simplest, an argument in braces:
Hello, {name}!
Add a type after a comma and the argument gets formatted rather than interpolated:
You have {count, number} unread messages.
Last synced {when, date, medium} at {when, time, short}.
{amount, number, ::currency/EUR} charged today.
The :: prefix introduces a skeleton — a compact description of the format, resolved per locale. ::currency/EUR renders €1,234.50 in English and 1 234,50 € in French without you writing either.
Add a style in braces and you get branching. The two you will use constantly are plural and select.
Plurals
{count, plural,
one {You have # unread message}
other {You have # unread messages}
}
Three things are happening:
countis matched against the target language’s CLDR plural rules to pick a category.- The category names —
zero,one,two,few,many,other— are the CLDR ones. They are labels for sets of numbers, not quantities;onein French covers 0 as well as 1. #is replaced by the formatted value ofcount, localised. Not{count}—#, which applies the locale’s number formatting.
other is mandatory. A message without it will not compile.
You can also match an exact number, which wins over the category:
{count, plural,
=0 {No messages}
one {# message}
other {# messages}
}
Use =0 when the empty case needs different wording, not merely a different plural form. “No messages” is a better empty state than “0 messages”, and in a language where one covers zero it is the only way to express it.
The critical consequence for translation: the number of branches is a property of the target language. English gives you one and other; Polish needs one, few, many, other; Arabic needs all six. A translator working from a two-branch English source has to produce a four- or six-branch target, and any tooling that copies the source structure across is producing broken messages.
Selection on gender or anything else
select matches a string argument against fixed keys:
{gender, select,
female {She updated her profile}
male {He updated his profile}
other {They updated their profile}
}
other is required here too, and it is not a synonym for “unknown” — treat it as the case that must read correctly when the value is missing, unexpected, or a gender your key list does not enumerate.
select is not limited to gender. It is a general string switch, useful for plan tiers, entity types, or any branch where the alternatives are meaningfully different sentences rather than a substituted noun.
Ordinals
{place, selectordinal,
one {#st}
two {#nd}
few {#rd}
other {#th}
}
selectordinal uses the language’s ordinal rules, which are a separate CLDR data set from the cardinal ones. English needs four ordinal categories and only two cardinal ones — a useful reminder that “how many plural forms does this language have” has two different answers depending on which question you are asking.
Nesting
Branches contain arbitrary message text, including further arguments and further branches:
{count, plural,
=0 {{name} has not shared any files yet}
one {{name} shared # file with {recipients, plural,
one {# person}
other {# people}
}}
other {{name} shared # files with {recipients, plural,
one {# person}
other {# people}
}}
}
This is legal, and it is also where readability collapses. A practical limit: nest one level, and if you need two, ask whether the sentence should be split or restructured first. Every level of nesting multiplies the number of branches the translator has to fill, and in a six-form language a doubly-nested plural is 36 cells.
Note the inner # inside a nested plural refers to the innermost plural’s argument. If you need the outer one, name it explicitly: {count}.
Escaping — the rule everyone gets wrong
Literal braces and apostrophes need escaping, and the mechanism is single quotes:
| You want | You write | Notes |
|---|---|---|
Literal { |
'{' |
The quote pair escapes the brace |
Literal } |
'}' |
|
Literal ' |
'' |
Two apostrophes, not a backslash |
{ inside a longer literal |
'{not an argument}' |
One quote pair can cover a whole run |
The failure mode is specific and nasty: a single unpaired apostrophe starts a quoted section that runs to the end of the message or the next apostrophe. French and Italian translations are full of apostrophes — l'utilisateur, dell'account — so a translator writing naturally can silently disable every placeholder after the first contraction.
✗ Broken: L'utilisateur {name} a {count} fichiers
→ everything after L' is treated as literal text
✓ Correct: L''utilisateur {name} a {count} fichiers
Some implementations only treat an apostrophe as an escape when it precedes {, } or #, which makes the bug locale- and library-dependent — it works in one renderer and breaks in another. Doubling the apostrophe is correct everywhere.
What translators actually get wrong
Four failure modes account for nearly all broken ICU messages, and only one of them is the translator’s fault:
- Translating the keywords. The syntax words are part of the syntax, not the copy. A translator seeing
plural,one,otherin a text field reasonably assumes they are words.{count, pluriel, un {# article} autre {# articles}}is a message that no longer compiles — and it is a completely understandable thing to have written. - Missing plural categories. The source has two branches, the target language needs four, and two never get written.
- Losing the
#. It reads like punctuation, so it gets dropped or replaced with a literal digit. - Unescaped apostrophes, as above.
None of these are catchable by reading the translation, because a broken ICU message looks like reasonable text. They are catchable by parsing it, which is the argument for validating messages in the translation tool rather than at build time — the person who can fix a mistranslated keyword is the translator, and they have moved on by the time CI fails.
WebTranslateIt parses ICU messages as they are saved: it detects mistranslated keywords across 12 languages and offers to autocorrect them, flags missing or surplus plural categories against the target language’s CLDR rules, and reports syntax errors, type mismatches and missing # references inline. Its Mistral and Gemini machine translation handlers are ICU-aware too — translating an English two-form message into Polish expands it to the four forms Polish requires, translating only the human-readable text and leaving the structure intact.
Where ICU MessageFormat is supported
| Platform | Support |
|---|---|
| Java | com.ibm.icu.text.MessageFormat (ICU4J). The JDK’s own java.text.MessageFormat is a much older, incompatible subset |
| C / C++ | ICU4C |
| JavaScript | intl-messageformat / FormatJS, messageformat.js; Intl.MessageFormat is on the standards track |
| React | react-intl (FormatJS) |
| Vue | vue-i18n with the ICU message compiler |
| Ruby | via the twitter_cldr or message_format gems; Rails I18n does not support it natively |
| Python | PyICU, or babel for the formatting subset |
| Android | Not native — plurals resources only |
| iOS | Not native — .stringsdict only |
The last two rows are the reason cross-platform teams often adopt an ICU library on mobile rather than the platform-native format: it is the only way to keep one message syntax, and therefore one translation memory, across web and mobile.
A working style guide
- Name your arguments.
{count}survives reordering and reads in a translation editor;{0}does not. - Put the whole sentence in one message. Concatenating a translated fragment onto a formatted number defeats the entire point.
- Always provide
other, and make it read correctly for unexpected values. - Use
=0for empty states that need different wording, not merely a different plural form. - Double every apostrophe in source text, so translators inherit the convention rather than discovering it.
- Validate on save, not at build time.
Frequently asked questions
- What is ICU MessageFormat?
- ICU MessageFormat is a syntax for writing a single translatable string that adapts to its arguments — choosing a plural form, selecting on gender, and formatting numbers and dates according to the locale. It comes from the International Components for Unicode project and is implemented in ICU4J, ICU4C, FormatJS, messageformat.js and most modern i18n libraries.
- How do I escape a curly brace in ICU MessageFormat?
- Wrap the literal text in single quotes: '{' produces a literal opening brace. To produce a literal apostrophe, double it: ''. This is the single most common source of confusion, because in French and Italian text apostrophes are everywhere and an unpaired one silently swallows the rest of the message.
- What is the difference between plural and selectordinal?
- plural uses a language's cardinal rules — one file, two files. selectordinal uses its ordinal rules — 1st, 2nd, 3rd. The categories look the same but the sets behind them are different: English needs two cardinal forms and four ordinal ones.
- Does ICU MessageFormat work with Android and iOS?
- Not natively. Android uses its own plurals resource and iOS uses .stringsdict, both of which cover plurals but not the nesting, selection and inline formatting ICU offers. Teams that want one message syntax across web, Android and iOS typically adopt an ICU library on each platform rather than the platform format.
Keep reading
-
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 formats cheat sheet: %s, %@, %{name}, {{name}}, {0}
Every string placeholder syntax you will meet, which language or framework uses it, and what breaks when a translator retypes one by hand.
-
Translation validations in WebTranslateIt (documentation)
The checks that catch a broken ICU message before it reaches production.
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.