# Plurals, Genders, and Variables: ICU MessageFormat for Developers

> Your English pluralization logic will break in Polish, Arabic, and Russian. Learn how ICU MessageFormat handles plurals, gender, numbers, and nested variables so your translated strings stay grammatical in every language.
- **Author**: Mohit Sapat
- **Published**: 2026-09-15
- **URL**: https://www.babelize.co/blogs/icu-messageformat-plurals-guide

---

<p class="lead">
    Your English pluralization logic will break in Polish, Arabic, and Russian. Learn how ICU MessageFormat handles plurals, gender, numbers, and nested variables so your translated strings stay grammatical in every language you ship.
</p>

<h2>ICU MessageFormat Syntax Cheat Sheet</h2>

<p>
    Every ICU construct you will actually use, with what it renders. The <code>#</code> inside a plural branch is replaced by the formatted number.
</p>

<table>
    <thead>
        <tr>
            <th>Construct</th>
            <th>Syntax</th>
            <th>Renders as</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Variable</td>
            <td><code>Hello, {name}!</code></td>
            <td>Hello, Ana!</td>
        </tr>
        <tr>
            <td>Plural</td>
            <td><code>{count, plural, one {# file} other {# files}}</code></td>
            <td>1 file - 5 files</td>
        </tr>
        <tr>
            <td>Exact match</td>
            <td><code>{count, plural, =0 {No files} one {# file} other {# files}}</code></td>
            <td>No files</td>
        </tr>
        <tr>
            <td>Select</td>
            <td><code>{gender, select, female {She} male {He} other {They}} replied</code></td>
            <td>They replied</td>
        </tr>
        <tr>
            <td>Ordinal</td>
            <td><code>{n, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}</code></td>
            <td>21st - 22nd - 23rd - 4th</td>
        </tr>
        <tr>
            <td>Offset</td>
            <td><code>{n, plural, offset:1 =0 {Nobody} =1 {{name}} one {{name} and # other} other {{name} and # others}}</code></td>
            <td>Ana and 2 others (n = 3)</td>
        </tr>
        <tr>
            <td>Number</td>
            <td><code>{price, number, ::currency/EUR}</code></td>
            <td>EUR 1,234.50 (en) - 1.234,50 EUR  (de)</td>
        </tr>
        <tr>
            <td>Date</td>
            <td><code>{when, date, medium}</code></td>
            <td>Oct 13, 2026</td>
        </tr>
        <tr>
            <td>Rich text tag</td>
            <td><code>You have &lt;b&gt;{count}&lt;/b&gt; new messages</code></td>
            <td>You have <b>3</b> new messages</td>
        </tr>
        <tr>
            <td>Escaping</td>
            <td><code>'{literal}'</code> and <code>''</code></td>
            <td>{literal} and '</td>
        </tr>
    </tbody>
</table>

<p>
    Two rules hold in every message: a <code>plural</code>, <code>select</code>, or <code>selectordinal</code> must end with an <code>other</code> branch, and the translator writes the whole sentence inside each branch. The sections below explain each construct and the mistakes that break them.
</p>

<h2>The Bug That Ships in Almost Every App</h2>

<p>
    Here is a line of code that exists in thousands of production codebases right now:
</p>

<pre><code>const label = count === 1 ? "1 item" : `${count} items`;</code></pre>

<p>
    It works perfectly. In English. The moment you translate it, it quietly becomes wrong in most of the world's languages, and nobody on your team will notice because nobody on your team reads Polish.
</p>

<p>
    English has two plural forms: singular and everything else. That is unusually simple. According to the <a href="https://cldr.unicode.org/index/cldr-spec/plural-rules" target="_blank" rel="noopener noreferrer">Unicode CLDR plural rules</a>, languages fall into six possible plural categories -- <code>zero</code>, <code>one</code>, <code>two</code>, <code>few</code>, <code>many</code>, and <code>other</code> -- and each language uses a different subset.
</p>

<table>
    <thead>
        <tr>
            <th>Language</th>
            <th>Plural categories</th>
            <th>Notes</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>Japanese, Korean, Chinese, Vietnamese</td>
            <td>1</td>
            <td>other only: same form for 0, 1, 2, 100</td>
        </tr>
        <tr>
            <td>English, German, Dutch, Turkish</td>
            <td>2</td>
            <td>one / other</td>
        </tr>
        <tr>
            <td>Spanish, Italian</td>
            <td>3</td>
            <td>one / many / other: many covers exact millions (1 millón de usuarios)</td>
        </tr>
        <tr>
            <td>French, Portuguese (BR)</td>
            <td>3</td>
            <td>one / many / other: 0 and 1 share one; many covers exact millions</td>
        </tr>
        <tr>
            <td>Czech, Slovak</td>
            <td>4</td>
            <td>one / few / many / other: many is for decimals</td>
        </tr>
        <tr>
            <td>Polish, Russian, Ukrainian</td>
            <td>4</td>
            <td>one / few / many / other</td>
        </tr>
        <tr>
            <td>Arabic</td>
            <td>6</td>
            <td>zero / one / two / few / many / other</td>
        </tr>
    </tbody>
</table>

<p>
    Russian is a good illustration of why a ternary cannot save you. The word for "file" changes based on the <em>last digit</em> of the number: 1 файл, 2 файла, 5 файлов, 21 файл, 22 файла, 25 файлов. There is no way to express that with <code>count === 1</code>. There is also no way to express it by adding a second key called <code>items_plural</code>, which is the workaround most teams reach for next.
</p>

<h2>What ICU MessageFormat Actually Is</h2>

<p>
    ICU MessageFormat is a string syntax, originally from the International Components for Unicode project, that moves grammatical decisions out of your code and into the translation string itself. It is supported by <code>next-intl</code>, <code>react-intl</code> / FormatJS, <code>i18next</code> (via the ICU plugin), Android, iOS, Java, and most translation management platforms. If you are still choosing a React library, our <a href="/blogs/next-intl-vs-react-i18next/">next-intl vs react-i18next comparison</a> covers how each one handles ICU.
</p>

<p>
    The basic ICU syntax is a placeholder in curly braces: <code>{argument}</code> for a plain value, or <code>{argument, type, options}</code> where the type is <code>plural</code>, <code>select</code>, <code>selectordinal</code>, <code>number</code>, <code>date</code>, or <code>time</code>. The same message in ICU, stored in an ordinary <a href="/blogs/json-translation-files-explained/">JSON translation file</a>, looks like this:
</p>

<pre><code>{
  "fileCount": "{count, plural, one {# file} other {# files}}"
}</code></pre>

<p>
    And the Polish translator, without touching your code, writes:
</p>

<pre><code>{
  "fileCount": "{count, plural, one {# plik} few {# pliki} many {# plików} other {# pliku}}"
}</code></pre>

<p>
    Your component stays identical in both cases:
</p>

<pre><code>t("fileCount", { count: files.length })</code></pre>

<p>
    That is the whole idea. <strong>The code passes data. The translation decides grammar.</strong> The <code>#</code> is a placeholder for the number, already formatted for the locale -- so a German user sees <code>1.234 Dateien</code> and an American sees <code>1,234 files</code> without you writing a single formatting branch.
</p>

<figure class="blog-figure">
    <img src="/blog/inline/icu-messageformat-plurals-guide-1.webp" alt="Diagram of ICU plural resolution: t(&quot;fileCount&quot;, { count: 5 }) passes the value, Intl.PluralRules(&quot;pl&quot;).select(5) returns the category many, and the many {# plików} branch renders 5 plików. English: 1 is one (1 file), 5 and 0 are other (5 files, 0 files). Polish: 1 is one (1 plik), 2 and 22 are few (2 pliki, 22 pliki), 5 is many (5 plików), 1.5 is other (1,5 pliku). Arabic uses all six categories: 0 zero, 1 one, 2 two, 3 few, 11 many, 100 other." width="1560" height="1060" loading="lazy" decoding="async" />
    <figcaption>The count picks a CLDR plural category per locale, and the category picks the branch. The same code works for English, Polish and Arabic.</figcaption>
</figure>

<h2>The Four Constructs Worth Knowing</h2>

<h3>1. Simple arguments</h3>

<p>
    The basic case -- a variable dropped into a sentence:
</p>

<pre><code>"greeting": "Welcome back, {name}!"</code></pre>

<p>
    This already buys you something important: the translator controls where <code>{name}</code> goes. In Japanese, the name comes before the greeting. If you had written <code>t("welcome") + " " + name</code>, the translator would have been stuck.
</p>

<h3>2. plural</h3>

<pre><code>"notifications": "{count, plural,
  =0 {No new notifications}
  one {# new notification}
  other {# new notifications}
}"</code></pre>

<p>
    Note the difference between <code>=0</code> and <code>zero</code>. The <code>=0</code> form is an <em>exact match</em> -- it fires whenever the value is literally 0, in any language. The <code>zero</code> category is a <em>grammatical</em> category used by languages like Arabic and Latvian, and it may not fire for the number 0 at all in a language that does not have it. Use <code>=0</code> for "empty state" copy and leave the named categories for grammar.
</p>

<h3>3. select</h3>

<p>
    For gender agreement and any other branching that is not numeric:
</p>

<pre><code>"invited": "{gender, select,
  male {He invited you to {project}}
  female {She invited you to {project}}
  other {They invited you to {project}}
}"</code></pre>

<p>
    This matters more than English developers expect. In Spanish, French, Russian, Hebrew, and Arabic, adjectives and past participles agree with gender. A message like "You are subscribed" has two different forms in Russian depending on who is reading it. If your product knows the user's gender, expose it as a <code>select</code> argument. If it does not, that is a real product decision -- tell your translators so they can pick a neutral phrasing instead of guessing.
</p>

<h3>4. selectordinal</h3>

<pre><code>"rank": "You finished {place, selectordinal,
  one {#st} two {#nd} few {#rd} other {#th}
} in the leaderboard"</code></pre>

<p>
    Ordinals follow their own rules that are unrelated to plural rules -- 1st, 2nd, 3rd, 4th, 21st, 22nd. Hardcoding a suffix table is a classic source of "22th" bugs.
</p>

<h2>ICU Plural Rules by Language</h2>

<p>
    These are the CLDR categories each language uses, with example numbers that land in each one. Cardinal rules drive <code>plural</code>; ordinal rules drive <code>selectordinal</code>. Your translation for a locale needs a branch for every category that locale uses, and nothing else.
</p>

<table>
    <thead>
        <tr>
            <th>Language</th>
            <th>Cardinal (plural)</th>
            <th>Ordinal (selectordinal)</th>
        </tr>
    </thead>
    <tbody>
        <tr>
            <td>English</td>
            <td>one (1) - other (0, 2, 3)</td>
            <td>one (1st, 21st) - two (2nd, 22nd) - few (3rd, 23rd) - other (4th)</td>
        </tr>
        <tr>
            <td>German, Dutch</td>
            <td>one (1) - other (0, 2, 3)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Spanish, Italian</td>
            <td>one (1) - many (1,000,000) - other (0, 2, 3)</td>
            <td>Spanish: other - Italian: many (8, 11, 80) - other</td>
        </tr>
        <tr>
            <td>French</td>
            <td>one (0, 1, 1.5) - many (1,000,000) - other (2, 3)</td>
            <td>one (1er) - other</td>
        </tr>
        <tr>
            <td>Portuguese</td>
            <td>one (0, 1, 1.5) - many (1,000,000) - other (2, 3)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Russian, Ukrainian</td>
            <td>one (1, 21, 31) - few (2-4, 22) - many (0, 5-20) - other (1.5)</td>
            <td>Russian: other - Ukrainian: few (3, 23) - other</td>
        </tr>
        <tr>
            <td>Polish</td>
            <td>one (1) - few (2-4, 22) - many (0, 5-21) - other (1.5)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Czech, Slovak</td>
            <td>one (1) - few (2-4) - many (1.5) - other (0, 5, 6)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Arabic</td>
            <td>zero (0) - one (1) - two (2) - few (3-10) - many (11-99) - other (100, 101)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Hebrew</td>
            <td>one (1) - two (2) - other (0, 3, 4)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Hindi</td>
            <td>one (0, 1) - other (2, 3)</td>
            <td>one (1) - two (2, 3) - few (4) - many (6) - other</td>
        </tr>
        <tr>
            <td>Latvian</td>
            <td>zero (0, 10-20) - one (1, 21, 31) - other (2-9)</td>
            <td>other</td>
        </tr>
        <tr>
            <td>Welsh</td>
            <td>zero (0) - one (1) - two (2) - few (3) - many (6) - other (4, 5, 7)</td>
            <td>zero - one - two - few - many - other</td>
        </tr>
        <tr>
            <td>Japanese, Chinese, Korean, Turkish</td>
            <td>Turkish: one (1) - other - others: other only</td>
            <td>other</td>
        </tr>
    </tbody>
</table>

<p>
    Two details catch people out. French and Portuguese put <strong>0</strong> in <code>one</code>, so "0 fichier" is singular. Spanish, French, Italian, and Portuguese now have a <code>many</code> category for exact millions ("1 millón <em>de</em> usuarios"), so a translation with only <code>one</code> and <code>other</code> will fall back to <code>other</code> there.
</p>

<p>
    Rules change between CLDR releases, so check against the runtime your users actually have instead of a table:
</p>

<pre><code>new Intl.PluralRules("pl").resolvedOptions().pluralCategories;
// ["few", "many", "one", "other"]

new Intl.PluralRules("en", { type: "ordinal" }).select(22);
// "two"  ->  22nd</code></pre>

<h2>Numbers, Dates, and Currency Inside Messages</h2>

<p>
    ICU also wraps the <code>Intl</code> formatting APIs, so you can keep formatting inside the message where the translator can see it in context:
</p>

<pre><code>"invoice": "Your invoice for {amount, number, ::currency/USD} is due {date, date, medium}"</code></pre>

<p>
    The rule to internalize: <strong>pass raw values, never pre-formatted strings.</strong> If you call <code>amount.toFixed(2)</code> before handing the value to your translation layer, you have permanently locked every locale into American decimal separators. Pass the <code>Number</code>. Pass the <code>Date</code>. Let the formatter decide. For formatting outside of messages, see our <a href="/blogs/javascript-intl-api-dates-numbers-currencies/">guide to the JavaScript Intl API</a>.
</p>

<h2>Nesting and Rich Text</h2>

<p>
    Real interface copy has links and bold spans in the middle of sentences. The wrong instinct is to split the sentence into three keys -- prefix, link text, suffix -- and concatenate. That produces unreadable translation files and breaks in any language with different word order.
</p>

<p>
    Instead, keep the sentence whole and pass the markup as a function:
</p>

<pre><code>// message
"terms": "By continuing you accept our &lt;link&gt;Terms of Service&lt;/link&gt;."

// component
t.rich("terms", {
  link: (chunks) =&gt; &lt;a href="/terms"&gt;{chunks}&lt;/a&gt;,
})</code></pre>

<p>
    The translator sees one coherent sentence and can move the link anywhere inside it. Your JSX stays in your code where it belongs.
</p>

<p>
    You can also nest constructs. This is legal and occasionally necessary:
</p>

<pre><code>"shared": "{count, plural,
  one {{gender, select,
    female {She shared # file}
    other {They shared # file}
  }}
  other {{gender, select,
    female {She shared # files}
    other {They shared # files}
  }}
}"</code></pre>

<figure class="blog-figure">
    <img src="/blog/inline/icu-messageformat-plurals-guide-2.webp" alt="Tree diagram of the nested &quot;shared&quot; message: {count, plural} branches into one and other, each containing {gender, select} that branches into female and other, giving four leaves: She shared # file, They shared # file, She shared # files, They shared # files. The highlighted path for { count: 1, gender: &quot;female&quot; } goes one, then female, producing &quot;She shared 1 file&quot;." width="1500" height="900" loading="lazy" decoding="async" />
    <figcaption>Nesting select inside plural multiplies the branches: two levels mean four full sentences to translate.</figcaption>
</figure>

<p>
    It is also where readability dies. If a message needs two levels of nesting, that is usually a signal to split it into two separate UI strings instead.
</p>

<h2>Mistakes That Show Up in Code Review</h2>

<ul>
    <li><strong>Concatenating translated fragments.</strong> <code>t("deleted") + " " + count + " " + t("items")</code> is unfixable by a translator. One key, one sentence.</li>
    <li><strong>Reusing a key in two places.</strong> "Open" as a button and "Open" as a status are the same word in English and different words in German. Key by meaning, not by string.</li>
    <li><strong>Omitting the <code>other</code> branch.</strong> It is the required fallback. A message without it throws at runtime in strict formatters.</li>
    <li><strong>Putting HTML in the message string.</strong> Raw <code>&lt;b&gt;</code> tags invite XSS through translation files and break when a translator mangles a tag. Use rich-text callbacks.</li>
    <li><strong>Translating the ICU syntax itself.</strong> A machine translator with no ICU awareness will happily translate the literal word <code>other</code> or reorder the braces, producing a message that fails to parse. Validate ICU syntax in CI.</li>
</ul>

<h2>Validating ICU in CI</h2>

<p>
    Because ICU messages are strings, a broken one is not a compile error -- it is a runtime crash on a page only your Polish users visit. Worth adding to your pipeline:
</p>

<ul>
    <li>Parse every message in every locale with the FormatJS parser and fail the build on syntax errors</li>
    <li>Check that the set of variables in each translation matches the source message -- a missing <code>{count}</code> is a silent content bug</li>
    <li>Assert that every plural message includes an <code>other</code> branch</li>
    <li>Snapshot-render a few numbers (0, 1, 2, 5, 21, 101) per locale and eyeball the output once</li>
</ul>

<p>
    A <a href="/blogs/pseudo-localization-testing-guide/">pseudo-localization pass</a> complements these checks: it exercises every message in a fake locale and exposes concatenated sentences and hardcoded strings before real translators see them.
</p>

<h2>When You Do Not Need ICU</h2>

<p>
    Most strings in a typical app are static labels with no variables. Those do not need ICU and wrapping them in it adds nothing. ICU earns its complexity specifically where a message contains a count, a name, a date, a currency, or a gendered reference -- which in practice is a minority of your keys, and the source of most of your localization bugs.
</p>

<h2>Frequently Asked Questions</h2>

<h3>What is ICU MessageFormat?</h3>
<p>
    ICU MessageFormat is a string syntax that lets a single translation key express plural rules, gender agreement, number formatting, and conditional text. It moves grammatical decisions out of application code and into the translation string, so each locale can supply its own plural categories and word order.
</p>

<h3>What are the ICU plural rules?</h3>
<p>
    ICU uses the Unicode CLDR plural categories zero, one, two, few, many, and other. Each language uses its own subset: English uses one and other, Polish and Russian use one, few, many, and other, Arabic uses all six, and Japanese uses only other. The other branch is required in every plural message.
</p>

<h3>What is the difference between =0 and zero in an ICU plural?</h3>
<p>
    <code>=0</code> is an exact match that fires whenever the value is literally zero, in any language, and is used for empty-state copy. <code>zero</code> is a grammatical plural category used by languages such as Arabic and Latvian, and it may not fire for the number 0 in languages that lack that category.
</p>

<h3>Which i18n libraries support ICU MessageFormat?</h3>
<p>
    next-intl and react-intl (FormatJS) support ICU natively. i18next supports it through the i18next-icu plugin. ICU is also supported on Android, iOS, Java, and most translation management platforms.
</p>

<h3>Should I put HTML inside my ICU messages?</h3>
<p>
    No. Raw HTML in translation files invites XSS and breaks when a translator mangles a tag. Use tag placeholders with a rich-text callback so the markup stays in your components while the translator keeps control of the full sentence.
</p>
<h2>The Short Version</h2>

<p>
    Pluralization is grammar, and grammar belongs to the language, not to your <code>if</code> statement. ICU MessageFormat exists so that a translator who has never seen your codebase can produce a correct Polish sentence without asking you to ship new code. Adopt it for every message containing a variable, validate the syntax in CI, and pass raw numbers and dates rather than formatted strings.
</p>

<p>
    Once ICU messages are in place, keeping every locale in sync as your source strings change becomes the next bottleneck -- which is exactly the part <a href="/">Babelize</a> automates, detecting new and changed keys and generating ICU-valid translations across your locale files through a Git-native workflow. See the <a href="https://docs.babelize.co/sdk/icu" target="_blank" rel="noopener noreferrer">Babelize ICU documentation</a> for how messages are validated.
</p>
---
- [All articles](https://www.babelize.co/blogs)