i18n Pluralization: CLDR Plural Rules, i18next & ICU (2026 Guide)
Pluralization is one of the most underestimated parts of internationalization. In English it looks trivial, one item versus two items, so most codebases start with something like count === 1 ? 'item' : 'items'. That single line is a bug in most of the world's languages.
This guide explains how plurals actually work across languages (via CLDR), how i18next implements them, and the handful of mistakes that quietly break plural output in production.
Table of contents
- Why pluralization is hard
- CLDR plural categories
- How i18next handles plurals
- Interpolating the count
- i18next v3 vs v4 plurals
- Ordinal plurals (1st, 2nd, 3rd)
- ICU message format plurals
- Common mistakes
- Managing plurals across languages
- Frequently asked questions
Why pluralization is hard
English has two plural forms: singular (1 item) and plural (0 items, 2 items, 100 items). That two-way split is the exception, not the rule.
- Japanese, Chinese, Korean: one form. Nouns do not change with quantity.
- French: singular covers 0 and 1, plural covers the rest, plus a separate form for large formatted numbers.
- Polish, Russian, Ukrainian: the form for 2, 3, 4 differs from the form for 5-21, which differs again from 22-24.
- Arabic: six distinct forms, including dedicated forms for 0, 1, and 2.
You cannot express this with if (count === 1). The rules are per-language, non-obvious, and defined by linguists, not developers. That standard set of rules is CLDR.
CLDR plural categories
The Unicode CLDR defines up to six plural categories. Every language maps each number to exactly one of them:
| Category | Meaning (varies by language) |
|---|---|
zero | quantity 0 (only some languages) |
one | singular-like quantity |
two | dual (only some languages) |
few | small quantity (language-specific) |
many | large quantity (language-specific) |
other | the catch-all / general plural |
How many a language actually uses:
| Language | Categories used | Count |
|---|---|---|
| Japanese, Chinese, Korean | other | 1 |
| English, German, Spanish, Italian | one, other | 2 |
| French | one, many, other | 3 |
| Polish, Russian, Ukrainian | one, few, many, other | 4 |
| Arabic, Welsh | zero, one, two, few, many, other | 6 |
The good news: you do not need to memorize these. The browser ships them. Intl.PluralRules returns the right category for any number and locale:
new Intl.PluralRules('en').select(1) // "one"
new Intl.PluralRules('en').select(2) // "other"
new Intl.PluralRules('pl').select(2) // "few"
new Intl.PluralRules('pl').select(5) // "many"
new Intl.PluralRules('ar').select(0) // "zero"How i18next handles plurals
i18next v4 is built directly on Intl.PluralRules. You add one JSON key per plural category the language needs, using the category name as a suffix:
{
"item_one": "{{count}} item",
"item_other": "{{count}} items"
}Then you call t() with a count:
t('item', { count: 1 }) // "1 item"
t('item', { count: 5 }) // "5 items"i18next runs Intl.PluralRules(lng).select(count), gets the category (one, other, …), and resolves item_<category>. For a language that needs more forms, you simply provide more keys. Polish:
{
"item_one": "{{count}} produkt",
"item_few": "{{count}} produkty",
"item_many": "{{count}} produktów",
"item_other": "{{count}} produktu"
}You write the same t('item', { count }) call in your code; the correct form is chosen per active language. That is the whole point of i18n pluralization: the logic lives in data, not in your components.
Interpolating the count
count doubles as an interpolation value, so {{count}} renders the number inside the string:
{ "cart_one": "You have {{count}} item in your cart", "cart_other": "You have {{count}} items in your cart" }Formatting the number (thousands separators, etc.) is a separate, locale-aware concern, handled with i18next's format function or Intl.NumberFormat. Keep translation files as .json; see the JSON translation files reference for structure conventions.
i18next v3 vs v4 plurals
If you are reading older tutorials or maintaining a legacy project, the plural format looks different. This is the single most common source of "my plurals stopped working":
| i18next v3 | i18next v4 (current) | |
|---|---|---|
| Two-form languages | key + key_plural | key_one + key_other |
| Multi-form languages | key_0, key_1, key_2, … | key_one, key_few, key_many, key_other |
| Rule source | bundled JSON rules | native Intl.PluralRules |
v4 keys are self-documenting (the suffix names the category) and no longer bundle a plural-rules table, which keeps the runtime smaller. Migrating a v3 project is a mechanical rename; the official migration guide and i18next-cli cover it.
Ordinal plurals (1st, 2nd, 3rd)
Cardinal plurals (1 item) and ordinal plurals (1st place) use different CLDR rules. i18next supports ordinals with the ordinal flag and _ordinal_* keys:
{
"place_ordinal_one": "{{count}}st place",
"place_ordinal_two": "{{count}}nd place",
"place_ordinal_few": "{{count}}rd place",
"place_ordinal_other": "{{count}}th place"
}t('place', { count: 1, ordinal: true }) // "1st place"
t('place', { count: 3, ordinal: true }) // "3rd place"
t('place', { count: 11, ordinal: true }) // "11th place"ICU message format plurals
If you prefer a single inline string over suffixed keys, the i18next-icu plugin adds ICU MessageFormat support:
{ "item": "{count, plural, one {# item} other {# items}}" }Both styles resolve to the same CLDR categories under the hood. Suffixed keys are the i18next default (simpler diffs, one string per form); ICU keeps everything in one string (denser, familiar to teams coming from FormatJS/react-intl). Pick one and stay consistent.
ICU itself is being succeeded by MessageFormat 2 (MF2), the new Unicode standard (Final Candidate since 2025), which expresses plurals through explicit .match selectors and handles gender-plus-plural combinations more cleanly. Adoption is still very early, so it is not yet the pragmatic default. See MessageFormat 2 in i18next: state of the migration for whether to adopt it today.
For how i18next's format compares to ICU, Fluent, Gettext and the rest of the JS ecosystem, see I18N in the Multiverse of Formats.
Common mistakes
- Passing
countas a string.t('item', { count: '5' })will not select correctly.Intl.PluralRulesneeds a number. - Missing categories. Providing only
_oneand_otherfor a language that needs_fewand_many(Polish, Russian) silently falls back and reads wrong to native speakers. - v3/v4 format mismatch. JSON in the old
key_plural/key_0format against a v4 runtime (or the reverse) resolves to the base key or nothing. See v3 vs v4. - Assuming English rules everywhere.
0is_otherin English but_zeroin Arabic. Do not special-casecount === 0globally. - Resources not loaded yet. If translations load asynchronously and you render before they arrive, you see raw keys, unrelated to plurals. See translations not loading.
Turn on debug: true in your i18next config to log exactly which key was resolved for each call.
Managing plurals across languages
The hard part is not the code, it is keeping the right set of plural forms filled in for every language. When you add Arabic, six forms per pluralized key suddenly need translating; when you add Japanese, five of them are redundant.
This is where a translation management system earns its place. Locize is built around the i18next plural model:
- The editor shows exactly the plural forms each language requires, no more and no less, so translators cannot miss
_fewfor Polish or leave_twoempty for Arabic. - Plural-aware validation flags keys where a required category is missing.
- New keys added via saveMissing arrive with the correct plural skeleton, ready for human or AI translation.
Get the CLDR rules right once, and adding a language becomes a translation task, not an engineering task, which is the whole goal of i18n.
Frequently Asked Questions
You call t(key, { count }) and i18next appends a suffix based on the CLDR plural category of that count for the active language, using the browser-native Intl.PluralRules. In English that means a _one and an _other key; in other languages it can be up to six keys (_zero, _one, _two, _few, _many, _other). You never write count === 1 logic yourself.
CLDR (the Unicode Common Locale Data Repository) defines up to six plural categories: zero, one, two, few, many, and other. Each language uses a subset. English uses one and other; Polish uses one, few, many, other; Arabic uses all six; Japanese and Chinese use only other. Intl.PluralRules returns the correct category for any number and locale.
It varies from one to six. Japanese, Chinese and Korean have one (no grammatical plural). English, German, Spanish and most Western European languages have two. French has three. Slavic languages like Polish and Russian have four. Arabic and Welsh have six. This is why a boolean singular/plural check fails outside English.
i18next v4 switched from numeric suffixes (key_0, key_1, key_2) and key_plural to the CLDR category names (key_one, key_two, key_few, key_many, key_other), powered by Intl.PluralRules. This makes plural keys self-documenting and consistent with the rest of the platform. Existing v3 projects can convert with the official migration tooling.
The usual causes: you passed count as a string instead of a number; the key is missing the correct CLDR suffix for the active language (for example only _one and _other while the language needs _few and _many); your JSON is still in the i18next v3 format while the runtime is v4 (or vice versa); or the language resources have not finished loading. Turn on debug: true to see which key i18next resolved.
Yes. Pass ordinal: true to t() and provide ordinal keys (key_ordinal_one, key_ordinal_two, key_ordinal_few, key_ordinal_other). In English these map to 1st, 2nd, 3rd and 4th+. i18next uses Intl.PluralRules with type "ordinal" to pick the right form.
Yes, via the i18next-icu plugin. That lets you write ICU syntax like {count, plural, one {# item} other {# items}} inline in a single string instead of using suffixed keys. Both approaches use the same underlying CLDR rules; suffixed keys are the i18next default, ICU is opt-in.
For languages whose CLDR rules include a zero category (Arabic, Latvian, Welsh), define a _zero key and it is used automatically for count 0. For languages without a zero category (like English, where 0 resolves to _other), add an explicit _zero key for a dedicated "no items" message, or branch on count === 0 before calling t().