Pluralization and singularization for Python
English · Spanish · Portuguese · French · Italian · Esperanto · Zero dependencies · Type-safe · Extensible
- Zero dependencies — pure Python standard library, nothing else to install
- Type-safe — full type hints,
py.typedmarker included (PEP 561) - 100% test coverage — 7,233 tests, every line is verified
- 6 languages — English, Spanish, Portuguese, French, Italian, Esperanto
- Extensible at runtime — add irregulars, rules, uncountables, or entire languages without touching source code
- Utility functions —
join(),ordinal(),template()for common UI tasks - Case preservation —
"Library"→"Libraries","BOX"→"BOXES" - Count-aware —
pluralize("item", count=1)→"item" - Hyphenated words —
"mother-in-law"→"mothers-in-law" - Unicode normalization — NFC/NFD inputs handled transparently
- Sphinx documentation — full API reference at mathiaspaulenko.github.io/pluralio
- Python 3.10+ — tested on 3.10, 3.11, 3.12, 3.13, and 3.14
- Installation
- Quick start
- How it works
- API reference
- Extending rules
- Comparison
- Performance
- Supported languages
- Roadmap
- Changelog
- Contributing
- Security
- License
pip install pluralioOr with uv:
uv add pluralioimport pluralio
# ── English (default) ──────────────────────────────────────────
pluralio.pluralize("cat") # "cats"
pluralio.pluralize("box") # "boxes"
pluralio.pluralize("child") # "children"
pluralio.pluralize("city") # "cities"
pluralio.singularize("cities") # "city"
pluralio.singularize("mice") # "mouse"
pluralio.singularize("children") # "child"
# ── Spanish ────────────────────────────────────────────────────
pluralio.pluralize("gato", lang="es") # "gatos"
pluralio.pluralize("lápiz", lang="es") # "lápices"
pluralio.pluralize("examen", lang="es") # "exámenes"
pluralio.singularize("lápices", lang="es") # "lápiz"
pluralio.singularize("alemanes", lang="es") # "alemán"
# ── Portuguese ────────────────────────────────────────────────
pluralio.pluralize("casa", lang="pt") # "casas"
pluralio.pluralize("coração", lang="pt") # "corações"
pluralio.pluralize("papel", lang="pt") # "papéis"
pluralio.pluralize("flor", lang="pt") # "flores"
pluralio.singularize("corações", lang="pt") # "coração"
pluralio.singularize("papéis", lang="pt") # "papel"
# ── French ────────────────────────────────────────────────────
pluralio.pluralize("chat", lang="fr") # "chats"
pluralio.pluralize("cheval", lang="fr") # "chevaux"
pluralio.pluralize("bateau", lang="fr") # "bateaux"
pluralio.pluralize("travail", lang="fr") # "travaux"
pluralio.pluralize("bijou", lang="fr") # "bijoux"
pluralio.singularize("chevaux", lang="fr") # "cheval"
pluralio.singularize("travaux", lang="fr") # "travail"
# ── Italian ────────────────────────────────────────────────────
pluralio.pluralize("gatto", lang="it") # "gatti"
pluralio.pluralize("amico", lang="it") # "amici"
pluralio.pluralize("libro", lang="it") # "libri"
pluralio.singularize("amici", lang="it") # "amico"
pluralio.singularize("libri", lang="it") # "libro"
# ── Esperanto ──────────────────────────────────────────────────
pluralio.pluralize("libro", lang="eo") # "libroj"
pluralio.pluralize("librojn", lang="eo") # "librojn" (already plural)
pluralio.singularize("libroj", lang="eo") # "libro"
# ── Count-aware ────────────────────────────────────────────────
pluralio.pluralize("item", count=1) # "item" (singular)
pluralio.pluralize("item", count=0) # "items" (plural)
pluralio.pluralize("item", count=5) # "items" (plural)
# ── Case preservation ─────────────────────────────────────────
pluralio.pluralize("Library") # "Libraries"
pluralio.pluralize("LIBRARY") # "LIBRARIES"
pluralio.singularize("Libraries") # "Library"
# ── Hyphenated words ──────────────────────────────────────────
pluralio.pluralize("mother-in-law") # "mothers-in-law"
pluralio.singularize("mothers-in-law") # "mother-in-law"
# ── Unicode normalization ─────────────────────────────────────
import unicodedata
nfd = unicodedata.normalize("NFD", "lápiz") # NFD form
pluralio.pluralize(nfd, lang="es") # "lápices" (NFC output)
# ── List supported languages ──────────────────────────────────
pluralio.supported_languages() # ["en", "eo", "es", "fr", "it", "pt"]
# ── Inspect word form ─────────────────────────────────────────
pluralio.is_plural("cats") # True
pluralio.is_singular("cat") # True
pluralio.is_plural("sheep") # True (uncountable — valid as both)
pluralio.is_singular("sheep") # True (uncountable — valid as both)
pluralio.is_plural("gatos", lang="es") # True
# ── Join words into a list ────────────────────────────────────
pluralio.join(["apple", "banana", "carrot"]) # "apple, banana, and carrot"
pluralio.join(["apple", "banana", "carrot"], final_sep="") # "apple, banana and carrot"
pluralio.join(["apple", "banana", "carrot"], conjunction="or") # "apple, banana, or carrot"
# ── Ordinals ──────────────────────────────────────────────────
pluralio.ordinal(1) # "1st"
pluralio.ordinal(2) # "2nd"
pluralio.ordinal(11) # "11th"
pluralio.ordinal(21) # "21st"
# ── Template interpolation ────────────────────────────────────
pluralio.template("I have {count} {word:pluralize}", count=5, word="cat") # "I have 5 cats"
pluralio.template("I have {count} {word:pluralize}", count=1, word="cat") # "I have 1 cat"
pluralio.template("The {word:singularize} is here", word="mice") # "The mouse is here"Every word goes through a three-step priority chain:
┌─────────────────────────────────────────────────────────────┐
│ Input word (stripped) │
└───────────────────────────┬─────────────────────────────────┘
▼
┌─────────────────────────┐
│ 1. Uncountable check │ ← highest priority
│ word in set? │
└────────────┬────────────┘
│ no
▼
┌─────────────────────────┐
│ 2. Irregular lookup │
│ word in dict? │
└────────────┬────────────┘
│ no
▼
┌─────────────────────────┐
│ 3. Regex rules │ ← first match wins
│ (ordered list) │
└────────────┬────────────┘
│ no match
▼
┌─────────────────────────┐
│ Return word unchanged │
└─────────────────────────┘
- Uncountable words (e.g.
"sheep","information") are returned as-is. - Irregular words (e.g.
"man"→"men") are looked up in a dictionary. - Regex rules are applied in order — the first matching pattern wins.
- The casing of the input is always preserved in the output.
| Function | Description |
|---|---|
pluralize(word, lang="en", count=None) |
Convert a word to its plural form. |
singularize(word, lang="en") |
Convert a word to its singular form. |
is_plural(word, lang="en") |
Check if a word is in plural form. |
is_singular(word, lang="en") |
Check if a word is in singular form. |
supported_languages() |
Return a sorted list of registered language codes. |
| Function | Description |
|---|---|
join(words, *, conjunction="and", final_sep=", ", sep=", ") |
Join words into a natural-language list. |
ordinal(number) |
Convert a number to its ordinal string (1 → "1st"). |
template(text, **kwargs) |
Interpolate pluralize/singularize into string templates. |
| Function | Description |
|---|---|
add_irregular(singular, plural, lang="en") |
Add an irregular pair (both directions). |
add_plural(singular, plural, lang="en") |
Add only the singular → plural direction. |
add_singular(plural, singular, lang="en") |
Add only the plural → singular direction. |
add_uncountable(word, lang="en") |
Mark a word as invariable. |
add_plural_rule(pattern, replacement, lang="en") |
Insert a pluralization regex rule (top priority). |
add_singular_rule(pattern, replacement, lang="en") |
Insert a singularization regex rule (top priority). |
register_language(lang, *, plural_rules=..., ...) |
Register a completely new language. |
| Function | Description |
|---|---|
LanguageRules |
Dataclass holding all rules for a language. |
register(rules) |
Register a LanguageRules instance. |
get_rules(lang) |
Retrieve rules for a language (raises ValueError if not found). |
pluralio is designed to be extended at runtime — no need to modify the source code.
Registers both pluralize and singularize directions:
pluralio.add_irregular("person", "people")
pluralio.pluralize("person") # "people"
pluralio.singularize("people") # "person"When you need just one direction (e.g. Spanish accent restoration):
# Only pluralize direction
pluralio.add_plural("joven", "jóvenes", lang="es")
pluralio.pluralize("joven", lang="es") # "jóvenes"
# Only singularize direction (accent restoration)
pluralio.add_singular("alemanes", "alemán", lang="es")
pluralio.singularize("alemanes", lang="es") # "alemán"pluralio.add_uncountable("data")
pluralio.pluralize("data") # "data"
pluralio.singularize("data") # "data"Custom rules are inserted at the top of the rule list (highest priority — first match wins):
pluralio.add_plural_rule(r"us$", "i")
pluralio.pluralize("cactus") # "cacti"
pluralio.add_singular_rule(r"i$", "us")
pluralio.singularize("cacti") # "cactus"pluralio.register_language(
"fr",
plural_rules=[(r"$", "s")],
singular_rules=[(r"s$", "")],
irregular_plurals={"cheval": "chevaux"},
uncountable={"information"},
)
pluralio.pluralize("chat", lang="fr") # "chats"
pluralio.pluralize("cheval", lang="fr") # "chevaux"
pluralio.singularize("chevaux", lang="fr") # "cheval"
pluralio.pluralize("information", lang="fr") # "information"How does pluralio compare to other Python inflection libraries?
| Feature | pluralio | inflect | pluralsingular | pluralizer |
|---|---|---|---|---|
| Spanish pluralization | ✅ 100% | ❌ 39% | ❌ 40% | |
| Spanish singularization | ✅ 100% | ❌ 29% | ❌ N/A | |
| Portuguese pluralization | ✅ 100% | ❌ | ❌ | ❌ |
| French pluralization | ✅ 100% | ❌ | ❌ | ❌ |
| Italian pluralization | ✅ 100% | ❌ | ❌ | ❌ |
| Esperanto pluralization | ✅ 100% | ❌ | ❌ | ❌ |
| Singularize | ✅ | ✅ | ✅ | ❌ |
Count-aware (count=1) |
✅ | ✅ | ❌ | ❌ |
| Case preservation | ✅ | ❌ | ❌ | ✅ |
| Hyphenated words | ✅ | ❌ | ❌ | ❌ |
| Unicode normalization (NFC/NFD) | ✅ | ❌ | ❌ | ❌ |
| Idempotency (ES) | ✅ | ❌ | ❌ | ❌ |
| Uncountables (ES) | ✅ ~87 | ❌ | ❌ | ❌ |
| Accent restoration (ES) | ✅ | ❌ | ❌ | |
| Runtime extensibility | ✅ | ❌ | ❌ | ✅ |
| Add custom languages | ✅ | ❌ | ✅ | ❌ |
| Zero dependencies | ✅ | ❌ | ✅ | ✅ |
Type hints (py.typed) |
✅ | ✅ | ❌ | ❌ |
| Sphinx documentation | ✅ | ❌ | ❌ | ❌ |
| Python 3.10+ | ✅ | ✅ | ✅ | ✅ |
| Test coverage | 100% | ~95% | ~60% | ~70% |
| Number to words | ❌ | ✅ | ❌ | ❌ |
Ordinals (1 → 1st) |
✅ | ✅ | ❌ | ❌ |
Indefinite articles (a/an) |
❌ | ✅ | ❌ | ❌ |
| Join words into a list | ✅ | ✅ | ❌ | ❌ |
| Present participle | ❌ | ✅ | ❌ | ❌ |
| Classical pluralization modes | ❌ | ✅ | ❌ | ❌ |
| Part-of-speech inflection | ❌ | ✅ | ❌ | ❌ |
| Template interpolation | ✅ | ✅ | ❌ | ❌ |
| English-only maturity | 6 languages | Deep EN | EN only | EN only |
pluralio is fast — no regex compilation at call time, lru_cache on regex application, and islower() short-circuit for the common case:
pluralize: ~1.8 µs/call
singularize: ~1.8 µs/call
Benchmark: 100,000 calls across 13 mixed-language words (English, Spanish, Portuguese, French, Italian) on Python 3.14.
| Language | Code | Regex rules | Irregulars | Uncountables | Status |
|---|---|---|---|---|---|
| English | en |
7 + 22 | 684 | 219 | ✅ Complete |
| Spanish | es |
9 + 8 | 352 + 81 extra singles | 92 | ✅ Complete |
| Portuguese | pt |
8 + 13 | 388 + 88 extra singles | 88 | ✅ Complete |
| French | fr |
6 + 4 | 104 + 27 extra singles | 81 | ✅ Complete |
| Italian | it |
19 + 12 | 239 + 0 extra singles | 144 | ✅ Complete |
| Esperanto | eo |
2 + 2 | 0 | 33 | ✅ Complete |
| Version | Goal | Status |
|---|---|---|
1.0.0 |
English + Spanish, core engine, extensibility API | ✅ Released |
1.5.0 |
Portuguese (pt) |
✅ Released |
1.6.0 |
French (fr) |
✅ Released |
1.7.0 |
Italian (it) |
✅ Released |
2.0.0 |
Rules restructured into pluralio/rules/ subpackage, performance optimization |
✅ Released |
2.1.0 |
Esperanto (eo) — trivial -j plural |
✅ Released |
2.2.0 |
Utility functions: join(), ordinal(), template() |
✅ Released |
2.3.0 |
Catalan (ca) — Romance, natural fit |
🔜 Planned |
3.0.0 |
German (de) — umlauts + multiple plural patterns |
🔜 Planned |
See CHANGELOG.md for the full history.
- 2.2.0 — Utility functions:
join(),ordinal(),template() - 2.1.2 — Bug fixes:
restore()cache clearing,_match_casedigit-only source,register_languageredundant call - 2.1.1 — Bug fixes: Esperanto double-plural,
register()cache,_match_caseempty target - 2.1.0 — Esperanto (
eo) support - 2.0.0 — Rules restructured into
pluralio/rules/subpackage, performance optimization
Contributions are welcome! Whether it's a bug report, a new language, or a feature suggestion — please read our guides first:
- 📖 Contributing Guide — development setup, coding standards, PR process
- 🤝 Code of Conduct — community expectations
- 🐛 Bug Report Template
- ✨ Feature Request Template
git clone https://github.com/MathiasPaulenko/pluralio.git
cd pluralio
pip install -e ".[dev]"
# Run checks
ruff check pluralio/ tests/
mypy pluralio/ tests/
pytest
# Build docs locally
pip install -e ".[docs]"
python -m sphinx -b html docs docs/_build/htmlIf you discover a security vulnerability, please see our Security Policy for responsible disclosure instructions. Do not open a public issue.
MIT — Copyright (c) 2025 Mathias Paulenko