Skip to content
July 5, 20265 min readGuides

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

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:

CategoryMeaning (varies by language)
zeroquantity 0 (only some languages)
onesingular-like quantity
twodual (only some languages)
fewsmall quantity (language-specific)
manylarge quantity (language-specific)
otherthe catch-all / general plural

How many a language actually uses:

LanguageCategories usedCount
Japanese, Chinese, Koreanother1
English, German, Spanish, Italianone, other2
Frenchone, many, other3
Polish, Russian, Ukrainianone, few, many, other4
Arabic, Welshzero, one, two, few, many, other6

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 v3i18next v4 (current)
Two-form languageskey + key_pluralkey_one + key_other
Multi-form languageskey_0, key_1, key_2, …key_one, key_few, key_many, key_other
Rule sourcebundled JSON rulesnative 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 count as a string. t('item', { count: '5' }) will not select correctly. Intl.PluralRules needs a number.
  • Missing categories. Providing only _one and _other for a language that needs _few and _many (Polish, Russian) silently falls back and reads wrong to native speakers.
  • v3/v4 format mismatch. JSON in the old key_plural / key_0 format against a v4 runtime (or the reverse) resolves to the base key or nothing. See v3 vs v4.
  • Assuming English rules everywhere. 0 is _other in English but _zero in Arabic. Do not special-case count === 0 globally.
  • 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 _few for Polish or leave _two empty 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

How does i18next handle plurals?

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.

What are CLDR plural categories?

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.

How many plural forms does a language have?

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.

What changed in i18next v4 pluralization?

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.

Why is my i18next plural not working?

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.

Does i18next support ordinal plurals (1st, 2nd, 3rd)?

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.

Does i18next support ICU MessageFormat plurals?

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.

How do you handle a "zero" / no-items message?

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().