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.

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

text
{count, plural,
  one {You have # unread message}
  other {You have # unread messages}
}

Three things are happening:

  • count is 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; one in French covers 0 as well as 1.
  • # is replaced by the formatted value of count, 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:

text
{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:

text
{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

text
{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:

text
{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.

text
✗ 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:

  1. Translating the keywords. The syntax words are part of the syntax, not the copy. A translator seeing plural, one, other in 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.
  2. Missing plural categories. The source has two branches, the target language needs four, and two never get written.
  3. Losing the #. It reads like punctuation, so it gets dropped or replaced with a literal digit.
  4. 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 =0 for 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

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.