Pluralize and Singularize a List of Dutch Words with Python

I work for Wehkamp, a Dutch e-commerce company. Dutch is a peculiar language because it has so many irregularities! A while back I decided to dive into pluralization, a small but interesting part of morphology. I wanted to create a small library that would help convert nouns into proper Dutch plurals, so I created the dutch-pluralizer.

It turns out... it's not so simple! Why? In Dutch e-commerce we love English words, and we also love to pluralize some Dutch words differently than Van Dale does. Nobody uses speelgoederen as the plural of speelgoed — but according to Van Dale, that's the correct form.

The library is based in part on Basismorfologie. Het meervoud in het Nederlands, a document from the Université catholique de Louvain. In this article we'll take a CSV of words and use multiple strategies to determine the plural and singular form of the word.

Preparation

Before we do anything in Python, let's install the required packages and define the configuration. We use dutch-pluralizer for Dutch words, inflect as an English fallback, and pandas to read and write the CSV file. Windows users also need libhunspell; see the system dependency instructions.

Packages

We'll use the following packages:

pip install dutch-pluralizer inflect pandas

Imports

Keeping the imports in one place makes the processing steps easier to read.

from importlib.metadata import version as installed_package_version

import inflect
import pandas as pd
from IPython.display import display

from dutch_pluralizer import NounEndingMap, Pluralizer
from dutch_pluralizer.speller import ensure_hunspell_nl

Configuration

First, we need to define how to read the CSV file and whether we want to filter the data while testing larger datasets. Our ending_overrides help the pluralizer project make better choices. Our full_overrides let us make certain choices explicitly.

input_csv_file_name = "data/test.csv"
input_csv_word_field = 1
input_csv_has_header = True
filter_text = ""

ending_overrides = {
    "mode": "mode",
    "mom": "moms",
    "cut": "cuts",
    "dress": "dresses",
    "scrub": "scrubs",
    "ing": "ing",
    "wax": "wax",
    "olie": "oliën",
    "otion": "otions",
    "speelgoed": "speelgoed",
    "ondergoed": "ondergoed",
    "make-up": "make-up"
}

full_overrides = {
    "mascara": {"singular": "mascara", "plural": "mascara's"},
    "beauty": {"singular": "beauty product", "plural": "beauty producten"},
    "stoppen met roken": {
        "singular": "stoppen met roken",
        "plural": "stoppen met roken",
    },
    "spieren en gewrichten": {
        "singular": "spieren en gewrichten",
        "plural": "spieren en gewrichten",
    },
    "scheren & ontharen": {
        "singular": "scheren & ontharen",
        "plural": "scheren & ontharen",
    },
}

Full overrides take precedence. Explicit ending overrides are applied before the library and its English fallback, so exceptions such as dressdresses remain predictable.

Test file

I'm using a test file with some random words. The words can be singular or plural. In our use case, most data is singular, but there is always the odd plural one, as data is never truly clean.

id,word
11223,kaas
23454,auto
54354,boek
12345,mascara
93844,beauty
29420,speelgoed
40229,dress
92113,mode
29223,huizen
74821,mom
18643,cut
59372,scrub
42716,glamping
83504,haarwax
27198,massageolie
96435,bodylotion
31867,ondergoed
65209,make-up
49731,stoppen met roken
87926,spieren en gewrichten
23458,scheren & ontharen

Reading the data

Let's use pandas to read and filter the data.

input_df = pd.read_csv(
    input_csv_file_name,
    header=0 if input_csv_has_header else None,
    dtype=str,
)
word_values = input_df.iloc[:, input_csv_word_field].fillna("").str.strip()
selected_rows = input_df.loc[
    word_values.ne("")
    & word_values.map(lambda value: not filter_text or filter_text in value)
]
words = (
    selected_rows.iloc[:, input_csv_word_field]
    .tolist()
)

print(f"Loaded {len(words)} words.")
print(f"Sample words: {words[:10]}")

Start your engines

To make things faster, we initialize the language engines centrally and reuse them. This avoids loading dictionaries repeatedly.

dutch_speller = ensure_hunspell_nl()
ending_overrides_map = NounEndingMap(ending_overrides)
dutch_pluralizer = Pluralizer(dutch_speller)
dutch_pluralizer.add_ending_overrides(ending_overrides_map)
english_inflector = inflect.engine()

Now that we've filtered out the words we don't need, we can continue with pluralization or singularization.

Pluralizing the words

We use five strategies, in this order:

  • The word has a full override, so we use it.
  • The Dutch pluralizer returns a plural.
  • The word is already plural, so we leave it unchanged.
  • An ending override supplies the plural.
  • inflect provides the English fallback.
def pluralize_word(word):
    if word in full_overrides:
        plural = full_overrides[word]["plural"]
        source = "full_override"
    else:
        plural = dutch_pluralizer.pluralize(word)
        if plural:
            source = "dutch_pluralizer"
        elif dutch_pluralizer.singularize(word):
            plural = word
            source = "unchanged"
        else:
            plural = ending_overrides_map.pluralize_by_endings(word)
            if plural:
                source = "ending_override"
            elif not dutch_speller.spell(word):
                if english_inflector.singular_noun(word):
                    plural = word
                    source = "unchanged"
                else:
                    plural = english_inflector.plural(word)
                    source = "inflect"
            else:
                plural = None
                source = "not_found"

    return {"word": word, "plural": plural, "source": source}
plural_results = selected_rows.iloc[:, input_csv_word_field].apply(pluralize_word).apply(pd.Series)
plural_df = pd.concat(
    [selected_rows[["id"]], plural_results[["word", "plural", "source"]]],
    axis=1,
)
display(plural_df)
id,word,plural,source
11223,kaas,kazen,dutch_pluralizer
23454,auto,auto's,dutch_pluralizer
54354,boek,boeken,dutch_pluralizer
12345,mascara,mascara's,full_override
93844,beauty,beauty producten,full_override
29420,speelgoed,speelgoed,dutch_pluralizer
40229,dress,dresses,ending_override
92113,mode,mode,dutch_pluralizer
29223,huizen,huizen,unchanged
74821,mom,moms,ending_override
18643,cut,cuts,ending_override
59372,scrub,scrubs,dutch_pluralizer
42716,glamping,glamping,dutch_pluralizer
83504,haarwax,haarwax,ending_override
27198,massageolie,massageoliën,dutch_pluralizer
96435,bodylotion,bodylotions,dutch_pluralizer
31867,ondergoed,ondergoed,dutch_pluralizer
65209,make-up,make-up,dutch_pluralizer
49731,stoppen met roken,stoppen met roken,full_override
87926,spieren en gewrichten,spieren en gewrichten,full_override
23458,scheren & ontharen,scheren & ontharen,full_override

Singularizing the words

Let's also handle singularization. It uses the corresponding override and fallback strategies.

def singularize_word(word):
    if word in full_overrides:
        singular = full_overrides[word]["singular"]
        source = "full_override"
    else:
        singular = ending_overrides_map.singularize_by_endings(word)
        if singular:
            source = "ending_override"
        else:
            plural = ending_overrides_map.pluralize_by_endings(word)
            if plural:
                singular = word
                source = "ending_override"
            else:
                singular = dutch_pluralizer.singularize(word)
                if singular:
                    source = "dutch_pluralizer"
                else:
                    if not dutch_speller.spell(word):
                        singular = english_inflector.singular_noun(word)
                        source = "inflect" if singular else "unchanged"
                    else:
                        source = "unchanged"
        singular = singular or word

    return {"word": word, "singular": singular, "source": source}
singular_results = selected_rows.iloc[:, input_csv_word_field].apply(singularize_word).apply(pd.Series)
singular_df = pd.concat(
    [selected_rows[["id"]], singular_results[["word", "singular", "source"]]],
    axis=1,
)
display(singular_df)
id,word,singular,source
11223,kaas,kaas,unchanged
23454,auto,auto,unchanged
54354,boek,boek,unchanged
12345,mascara,mascara,full_override
93844,beauty,beauty product,full_override
29420,speelgoed,speelgoed,ending_override
40229,dress,dress,ending_override
92113,mode,mode,ending_override
29223,huizen,huis,dutch_pluralizer
74821,mom,mom,ending_override
18643,cut,cut,ending_override
59372,scrub,scrub,ending_override
42716,glamping,glamping,ending_override
83504,haarwax,haarwax,ending_override
27198,massageolie,massageolie,ending_override
96435,bodylotion,bodylotion,ending_override
31867,ondergoed,ondergoed,ending_override
65209,make-up,make-up,ending_override
49731,stoppen met roken,stoppen met roken,full_override
87926,spieren en gewrichten,spieren en gewrichten,full_override
23458,scheren & ontharen,scheren & ontharen,full_override

Running as a CLI tool

You can also use the library directly from the command line, either through the Python CLI or through Docker.

With Python

The library works as a command-line tool. Install it once and pipe words directly:

$ echo "kaas" | python -m dutch_pluralizer
kazen

Singularize works the same way:

$ printf 'kazen\nboeken\nhuizen\n' | python -m dutch_pluralizer -s
kaas
boek
huis

With Docker

If you don't want to install Python at all, the library is also available as a Docker image (~59 MB):

$ docker run --rm ghcr.io/keescbakker/dutch-pluralizer-py kaas
kazen

Final thoughts

This setup isn't perfect — Dutch morphology is too complex for a simple rule-based approach to get everything right. But with the override mechanism, we can fix edge cases as they come up without changing the core logic.

Words that are neither Dutch nor English — French loanwords, brand names, and product codes — still need manual curation. Honestly, that's fine.

At least it's cheaper than asking an LLM to do all of this. 🤡

The code is on GitHub, so check it out: dutch-pluralizer-py or our notebook.

Changelog

  • Updated the code examples and replaced duplicated code blocks with includes.
  • Added CLI stdin pipe and Docker usage sections.
  • Updated with full code examples, production experience, and CFFI migration notes.
  • Initial article.
expand_less brightness_auto