pluralio
Pluralization and singularization for Python
6 languages · 7,280 tests · Zero dependencies · Type-safe · Extensible at runtime
Features
- Zero dependencies — pure Python standard library, nothing else to install
- Type-safe — full type hints,
py.typedmarker included (PEP 561) - 100% test coverage — 7,280 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
Table of contents
- Installation
- Quick start
- How it works
- API reference
- Extending rules
- Comparison
- Performance
- Supported languages
- Roadmap
- Changelog
- Contributing
- Security
- License
Installation
pip install pluralio
Or with uv:
uv add pluralio
Quick start
import 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"
How it works
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.
API reference
Core functions
| 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. |
Utility functions
| 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. |
Extensibility functions
| 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. |
Low-level registry
| 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). |
snapshot() |
Deep-copy the current registry state (test isolation). |
restore(state) |
Restore a registry state returned by snapshot(). |
Extending rules
pluralio is designed to be extended at runtime — no need to modify the source code.
Add an irregular word
Registers both pluralize and singularize directions:
pluralio.add_irregular("person", "people")
pluralio.pluralize("person") # "people"
pluralio.singularize("people") # "person"
Add only plural or singular direction
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"
Add an uncountable / invariable word
pluralio.add_uncountable("data")
pluralio.pluralize("data") # "data"
pluralio.singularize("data") # "data"
Add a regex rule
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"
Register a new language
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"
Comparison
How does pluralio compare to other Python inflection libraries?
| Feature | pluralio | inflect | pluralsingular | pluralizer |
|---|---|---|---|---|
| Spanish pluralization | ✅ 100% | ❌ 39% | ⚠️ 74% | ❌ 40% |
| Spanish singularization | ✅ 100% | ❌ 29% | ⚠️ 87% | ❌ 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) | ✅ ~92 | ❌ | ❌ | ❌ |
| Accent restoration (ES) | ✅ | ❌ | ⚠️ Partial | ❌ |
| 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 |
Performance
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.
Supported languages
| Language | Code | Regex rules | Irregulars | Uncountables | Status |
|---|---|---|---|---|---|
| English | en |
7 + 22 | 684 | 219 | ✅ Complete |
| Spanish | es |
9 + 8 | 354 | 92 | ✅ Complete |
| Portuguese | pt |
9 + 13 | 393 | 88 | ✅ Complete |
| French | fr |
6 + 4 | 100 | 92 | ✅ Complete |
| Italian | it |
19 + 12 | 249 | 126 | ✅ Complete |
| Esperanto | eo |
4 + 2 | 0 | 39 | ✅ Complete |
Roadmap
| 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 |
Bug fixes: language-code normalization, rule corrections (FR/IT/PT), docs | ✅ Released |
2.4.0 |
Catalan (ca) — Romance, natural fit |
🔜 Planned |
3.0.0 |
German (de) — umlauts + multiple plural patterns |
🔜 Planned |
Changelog
See CHANGELOG.md for the full history.
Recent releases
- 2.3.0 — Bug fixes: language-code normalization, rule corrections (FR/IT/PT), doctests
- 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
Contributing
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
Quick start for contributors
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/html
Security
If you discover a security vulnerability, please see our Security Policy for responsible disclosure instructions. Do not open a public issue.
License
MIT — Copyright (c) 2025 Mathias Paulenko
Metadata
Release files for pluralio 2.3.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pluralio-2.3.0.tar.gz | 124.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pluralio-2.3.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 180.9 kB
Release files / pluralio-2.3.0.tar.gz
| Download URL | pluralio-2.3.0.tar.gz |
|---|---|
| Size | 124.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
44f4aa9d83cff0dd50341b00d9409557ed8e4152668d42d995167bd46929948f
|
|
BLAKE2b-256 checksum How to use checksums |
3deec28fd715cba83c7036cfb661f47a2f657c40ccc08af60da673db0231b69f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency logRelease files / pluralio-2.3.0-py3-none-any.whl
| Download URL | pluralio-2.3.0-py3-none-any.whl |
|---|---|
| Size | 56.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3bb1c5e7c6e42c49b2f37b5f56d052c8f43f837615641b47897df7f756b25e40
|
|
BLAKE2b-256 checksum How to use checksums |
5f203b0ae6a70a942cba3c92b2c72451e76cce4209e1315d98ac68f57894291b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 6, 2026.
Transparency log