Pluralization Rules Across Languages

Pick the wrong plural form for a count and you ship strings like “3 item” or “1 файлов” — grammatically broken text that no spell-checker catches and that QA only spots in the target language. The cause is almost always the same mistake: treating plurals as a binary “one vs. many” switch when CLDR defines up to six plural categories — zero, one, two, few, many, other — and assigns them differently in every language. Arabic uses all six. Welsh uses five. English uses two. Japanese uses one. This page shows how to select the correct category at runtime with Intl.PluralRules, author messages that survive translation, and gate the whole thing in CI before broken grammar reaches users.

The same count resolves to a different CLDR category in each language A grid of six counts against four languages. English uses only one and other, Russian adds few and many, French uses one, other and many, and Arabic uses all six categories. The number 1 is the sole row where every language agrees on "one"; every other count splits across categories, showing that a plural category is grammatical, not arithmetic. One count, four languages: category is grammatical, not arithmetic count English 2 forms Russian 4 forms Arabic 6 forms French 3 forms 0 other many zero one 1 one one one one 2 other few two other 5 other many few other 21 other one many other 100 other many other other Only count 1 agrees across all four; Russian 21 is "one", Arabic 100 is "other" — the number never predicts the category.
Each cell is the category Intl.PluralRules(locale).select(count) actually returns. The number alone predicts nothing — one is not "the number 1", and English collapsing almost everything to other is why "one vs. many" logic breaks in Russian and Arabic.

Prerequisites

Concept & spec: what a “plural category” actually is

A plural category is a grammatical bucket. For a given language, every number — and, with ICU, every formatted number including decimals — maps to exactly one of six possible categories: zero, one, two, few, many, other. The category names are universal, but which numbers land in which bucket is per-language, and a language only uses the buckets it grammatically needs.

Cardinal category sets by language Japanese, Chinese and Korean define a single other category, so one branch is enough. English, German and Spanish define one and other. Russian, Polish and Ukrainian define one, few, many and other. Arabic and Welsh define all six categories, so a message needs six branches to be complete. Category sets you must author for Categories CLDR defines Branches you write ja, zh, ko other 1 en, de, es one, other 2 ru, pl, uk one, few, many, other 4 ar zero, one, two, few, many, other 6 cy zero, one, two, few, many, other 6
A catalogue is only complete when it carries every category its locale defines — one for Japanese, six for Arabic.

The authority is the Unicode CLDR Language Plural Rules data, specified in Unicode TR35 Part 2 (Numbers). Crucially, one does not mean “the number 1” — it means “the form this language uses for quantities that behave grammatically like one.” In English one is exactly n = 1. In Russian one covers 1, 21, 31, 101… (numbers ending in 1 but not 11). In French, one covers both 0 and 1. The categories are abstractions over morphology, not arithmetic.

The runtime contract is ECMA-402’s Intl.PluralRules, which exposes exactly these CLDR rules to JavaScript. This sits inside the broader Core i18n Architecture & Locale Negotiation area: pluralization runs after the locale is resolved and before the final string is composed, and it shares its category machinery with the ICU Message Format Deep Dive — ICU plural blocks call the same CLDR rules under the hood.

CLDR distinguishes two rule sets, selected by type:

  • Cardinal (type: 'cardinal', the default) answers “how many?” — 1 file, 5 files.
  • Ordinal (type: 'ordinal') answers “in what position?” — 1st, 2nd, 3rd. English has four ordinal categories (one → “1st”, two → “2nd”, few → “3rd”, other → “4th”), so a count formatter and a rank formatter need different rule sets even within one language.

Plural category explorer

With JavaScript enabled, this panel asks your own runtime which CLDR category each count selects in English, Polish, Russian, Arabic and Japanese. The tables and rules above stand on their own without it.

Step-by-step implementation

Step 1 — Resolve the category, never hardcode the threshold

Four rules for authoring a plural message Ask the runtime for the category list with Intl.PluralRules resolvedOptions rather than trusting a hand-written table, author one branch for every category it returns plus a mandatory other branch, keep the whole clause inside each branch instead of concatenating fragments, and gate the parity between catalogue branches and runtime categories in continuous integration. Authoring a plural message without guessing 1 Ask the runtime, never a table new Intl.PluralRules(locale).resolvedOptions() 2 Author one branch per category and always an other branch 3 Never build a sentence by concatenation the whole clause lives inside the branch 4 Gate parity in CI catalogue categories = runtime categories
Rule one removes the guesswork; rule four keeps it removed as locales are added.

Construct an Intl.PluralRules instance per locale and call .select() with the count. Never write count === 1 ? singular : plural: that logic is wrong for every language except English-like ones, and silently wrong (no error, just bad grammar).

const pr = new Intl.PluralRules('ru', { type: 'cardinal' });
pr.select(1);   // 'one'   → "1 файл"
pr.select(2);   // 'few'   → "2 файла"
pr.select(5);   // 'many'  → "5 файлов"
pr.select(21);  // 'one'   → "21 файл"

The same call for 'en' returns only 'one' or 'other'; for 'ar' it can return any of the six. Your code does not branch on the language — CLDR does.

Step 2 — Key your messages by category, with other mandatory

Store one string per category the target language uses, and always provide other as the floor. The keys are the CLDR category names; selection picks the matching key or degrades to other.

type PluralForms = Partial<Record<Intl.LDMLPluralRule, string>> & { other: string };

function plural(locale: string, count: number, forms: PluralForms): string {
  const cat = new Intl.PluralRules(locale).select(count);
  const template = forms[cat] ?? forms.other; // 'other' is always defined
  return template.replace('#', new Intl.NumberFormat(locale).format(count));
}

plural('pl', 5, { one: '# plik', few: '# pliki', many: '# plików', other: '# pliku' });
// → "5 plików"

Step 3 — Prefer ICU MessageFormat for translator-authored strings

Hand-rolled forms maps are fine for code-owned strings, but translators need a single self-contained string. ICU plural blocks embed every category inline; the # token is replaced by the locale-formatted count, and =N exact matches (e.g. =0) override category rules for special-cased copy.

{count, plural,
  =0 {No files}
  one {# file}
  few {# files}
  many {# files}
  other {# files}}

Let the library (formatjs, intl-messageformat, i18next-icu) evaluate this. Do not parse ICU yourself — the spec’s escaping and nesting rules are covered in the ICU Message Format Deep Dive.

Step 4 — Handle ordinals with the right type

For “1st place” UI, switch to ordinal rules and select a suffix by category. Reusing the cardinal one/other split here produces “1th”/“2th” in English.

const ord = new Intl.PluralRules('en', { type: 'ordinal' });
const suffix = { one: 'st', two: 'nd', few: 'rd', other: 'th' } as const;
const rank = 22;
`${rank}${suffix[ord.select(rank)]}`; // ord.select(22) → 'two' → "22nd"

Configuration reference

Option Type Description / default
locale string (BCP-47) First constructor argument; the resolved tag whose CLDR rules apply. No default — pass an explicit tag, never rely on the host.
type 'cardinal' | 'ordinal' Which rule set to use. Default 'cardinal'. Use 'ordinal' only for rank UI.
minimumFractionDigits number Affects category for fractional counts (e.g. 1.0 is other, not one, in many locales). Default 0.
maximumFractionDigits number Upper bound on fraction digits considered during selection. Default depends on locale.
minimumSignificantDigits number Alternative precision model; influences which visible digits drive the rule. Optional.
roundingMode string How fractional input rounds before selection (Node 18+/modern engines). Default 'halfExpand'.

pr.resolvedOptions().pluralCategories returns the array of categories the chosen locale actually uses — drive your catalog validation off that, never off a hardcoded list.

Framework variants

React / Next.js (react-intl / formatjs). Author ICU plural strings in your message catalog and render with <FormattedMessage id="files" values={{ count }} /> or intl.formatMessage. formatjs validates ICU syntax at extraction time, so a missing brace fails the build rather than the browser.

Vue / Nuxt (vue-i18n). vue-i18n’s native |-delimited plural syntax only supports a positional 2–3 form list and does not implement full CLDR — wire pluralizationRules per locale, or switch the message format to ICU so few/many resolve correctly for Slavic and Arabic.

Angular. $localize and the i18nPlural pipe accept ICU plural blocks directly in templates; the Angular compiler extracts them into XLIFF, preserving each category as a separate translation unit.

Node.js backend. Use Intl.PluralRules directly (Steps 1–2) or intl-messageformat for ICU strings. Confirm the runtime ships full ICU — a small-icu build collapses many locales to English rules, so pr.select(5) for Russian wrongly returns 'other'.

Verification

Assert category selection against CLDR-known values per locale, and check that every catalog entry covers the categories its locale requires.

import { test, expect } from 'vitest';

test('Russian cardinal categories', () => {
  const pr = new Intl.PluralRules('ru');
  expect(pr.select(1)).toBe('one');
  expect(pr.select(3)).toBe('few');
  expect(pr.select(5)).toBe('many');
  expect(pr.select(0)).toBe('many');
});

test('catalog covers required categories', () => {
  const required = new Intl.PluralRules('ar').resolvedOptions().pluralCategories;
  const provided = Object.keys(catalogs.ar.items_count);
  for (const cat of required) expect(provided).toContain(cat);
});

In CI, fail the job when any locale’s catalog omits a required category — that single gate prevents the “3 item” class of bug from ever merging.

Where plural categories meet gender and case

Plural selection is the most visible piece of grammatical agreement, but it is rarely the only one in a sentence. Languages that inflect for gender or case make the surrounding words depend on the noun as well as on the count, and a message that only branches on the count will still read wrongly.

The pattern that scales is to branch on gender with select on the outside and on count with plural on the inside. Gender is a property of the referent, known to your data layer; count is a property of the moment, known to your render. Ordering them that way keeps each branch a complete, translatable clause. What does not scale is composing the sentence from fragments — a noun phrase pulled from one key and a verb from another — because the correct inflection of the second fragment depends on the first, and no message format can express that dependency across keys.

Case adds a further constraint in Slavic and Finno-Ugric languages: the same noun takes a different ending depending on its grammatical role. This is one of the strongest arguments against building sentences by concatenation. If the noun appears as a translatable fragment slotted into a template, the translator has no way to inflect it for the role the template puts it in. Keep the whole clause inside the branch and the problem disappears, at the cost of some repetition in the catalogue — repetition that translation memory then largely absorbs.

Cardinal, ordinal, and range: three different rule sets

CLDR publishes three independent plural rule sets, and using the wrong one produces output that looks almost right. Cardinal rules answer “how many” and drive Intl.PluralRules in its default mode. Ordinal rules answer “in what position” and require { type: 'ordinal' }; in English they yield four categories rather than two, which is why a hand-rolled st/nd/rd/th suffix works until it meets 11, 12 and 13. Range rules govern phrases such as “1–3 files” and are exposed through Intl.PluralRules.prototype.selectRange, which takes the two endpoints and returns the category for the phrase as a whole — a category that is not derivable from either endpoint alone.

The practical rule is to decide which question the string is answering before choosing the API. A leaderboard position is ordinal. A cart quantity is cardinal. A “showing 1–20 of 340” summary needs the range form for the first number pair and a cardinal form for the total, in the same sentence. Mixing them up produces “1st file” where “1 file” belongs, or a range phrase that agrees with the wrong endpoint.

Ordinal categories also differ from cardinal categories within the same language, which surprises people who assume a locale has one plural system. English cardinal has two categories; English ordinal has four. Welsh cardinal has six; Welsh ordinal has five. Because the two sets are unrelated, a catalogue that carries the right branches for cardinals can still be incomplete for ordinals, and only a per-rule-set parity check will catch it.

Common pitfalls

  • Binary thinking. Assuming one/other is enough drops few/many for Slavic and the full set for Arabic. Drive variants off resolvedOptions().pluralCategories. See Handling Pluralization in Arabic and Slavic Languages.
  • one means “1”. It does not. Russian one includes 21 and 101; French one includes 0. Trust .select(), not your intuition about the number.
  • small-icu runtime. A trimmed ICU build silently collapses non-English locales to one/other. Verify process.versions.icu and pr.resolvedOptions().pluralCategories.length.
  • Catalog vs. CLDR drift. A TMS exports a category your locale doesn’t use (or omits one it needs). When .select() returns a category with no string, you get a blank or wrong form — diagnose with CLDR Plural Category Mismatch Debugging.
  • Cardinal rules for ordinals. Produces “1th”. Pass type: 'ordinal' for rank UI.
  • Hand-replacing # outside ICU. If you bypass the ICU library you also bypass locale number formatting; always run the count through Intl.NumberFormat.

FAQ

Why does Intl.PluralRules('en').select(1.5) return other, not one?

In English CLDR cardinal rules, one is defined for the integer value 1 with no visible fraction digits. Any value with a fractional part (1.5, 1.0 when displayed as “1.0”) falls into other. This is why “1.5 stars” is grammatically plural in English. The minimum/maximumFractionDigits options influence this because they change which digits are “visible” for the rule.

Is zero the same as the count being 0?

No. zero is a grammatical category that only a few languages (Arabic, Welsh, Latvian) use, and it does not always correspond to the number 0 — Arabic’s zero rule matches n = 0, but most languages route 0 into other (English) or one (French). To special-case the literal number zero in copy (“No files”), use an ICU =0 exact match, which is independent of the CLDR zero category.

Can I derive plural categories from the language part of the locale alone?

Mostly, yes — CLDR plural rules key off the language subtag (pt, not pt-BR), with a handful of region-sensitive exceptions. Still pass the full resolved tag to Intl.PluralRules; it normalizes correctly, and hardcoding the bare language risks missing the rare regional rule and couples you to assumptions CLDR may revise.

Do I need separate strings for cardinal and ordinal, or can one catalog entry cover both?

They are different rule sets and almost always different copy (“3 files” vs. “3rd file”), so keep them as separate catalog entries. A single ICU string can only carry one selectordinal or plural block per placeholder; mixing them means two placeholders or two messages.

Part of Core i18n Architecture & Locale Negotiation.