skip to content

In Django, how do you translate a count-dependent message, such as the number of cart items, in Python and in templates?

level: middleimportance: should knowfreq 42%

answer

  1. singular, plural, number
  2. not every language has two forms
  3. count counter=… and {% plural %}
  4. trimmed for indented blocks

basics

~20 s

Use ngettext(singular, plural, count) in Python, then interpolate the count, and {% blocktranslate count counter=... %}…{% plural %}…{% endblocktranslate %} in templates. Never branch on count == 1 yourself: the catalog's plural rule picks the right form per language.

solid answer

~40 s

`ngettext("%(count)d item in your cart", "%(count)d items in your cart", count) % {"count": count}` passes the number to the catalog. The catalog's `Plural-Forms` rule then chooses which translated form to use, and French, German and Polish each have their own rule. An `if count == 1` in your code is wrong for many languages: Django's French catalog treats 0 as singular, while German treats it as plural. In templates, `{% blocktranslate count counter=cart.items|length %}…{% plural %}…{% endblocktranslate %}` compiles to the same `ngettext()` call, and `trimmed` collapses the block's indentation into one clean msgid. For import-time strings, `ngettext_lazy()` accepts a dictionary key name instead of a number and reads the count at interpolation. Both forms must use the same placeholder names.

code

python · 10 lines
python
from django.utils.translation import ngettext


def cart_summary(cart):
    count = cart.items.count()
    return ngettext(
        "%(count)d item in your cart, total %(total)s",
        "%(count)d items in your cart, total %(total)s",
        count,
    ) % {"count": count, "total": cart.total_display}

go deeper

for a junior

Recall ngettext() for counted messages in Python and blocktranslate count with {% plural %} in templates.

for a middle

Explain that the catalog's plural rule chooses the form, why placeholder names must match, and ngettext_lazy's key-name trick.

for a senior

Catch English-only plural logic in review and use trimmed so template reformatting never breaks existing translations.

for a principal

Make sure new locales are tested with counts like 0, 1, 2 and 5, since plural bugs rarely show in the source language.

## Why plurals need their own function English has two forms, "1 item" and "2 items", so it is tempting to write `if count == 1`. Other languages differ: - **German** follows English: singular only for 1 (`plural=(n != 1)` in Django's German catalog); - **French** uses the singular for **0 and 1**, "0 article, 1 article, 2 articles". Django's French catalog declares three plural forms; - **Polish** has four forms, chosen by rules on the last digits. Only the translator's catalog knows the rule, via its `Plural-Forms` header. Your code must therefore hand the **number** to the translation machinery and let it choose. ## In Python: `ngettext()` and friends `ngettext(singular, plural, number)` takes the English singular msgid, the English plural msgid and the count. It returns the correct translated form for the active language, which you then interpolate: ```python from django.utils.translation import ngettext text = ngettext( "%(count)d item in your cart", "%(count)d items in your cart", count, ) % {"count": count} ``` Rules that trip people up: 1. **Interpolate after the call.** `ngettext()` returns a template string. The `%` step fills it in. 2. **Use the same placeholder names in both forms.** If the singular uses `%(name)s` and the plural `%(plural_name)s`, `compilemessages` rejects the translation because a format specification doesn't exist in the msgid. 3. **Don't pre-select words by count.** Picking `verbose_name` or `verbose_name_plural` yourself and passing it in reintroduces the English rule. Put the noun inside both msgids instead. 4. **`npgettext(context, singular, plural, number)`** adds a context marker. ## Lazy plurals: `ngettext_lazy()` In a form or model class body you don't know the count yet. `ngettext_lazy()` therefore accepts either an integer or a **key name** as `number`, and reads the count from the interpolation dict later: ```python error_message = ngettext_lazy( "You can order at most %(limit)d item", "You can order at most %(limit)d items", "limit", ) # later, inside clean(): raise ValidationError(self.error_message % {"limit": limit}) ``` If `number` is omitted and the string has exactly one unnamed placeholder, `error_message % limit` works as well. A missing key raises a `KeyError` that names the key needed to choose between singular and plural. ## In templates: `blocktranslate count` The template version binds a counter and separates the forms with `{% plural %}`: ```django {% load i18n %} {% blocktranslate trimmed count counter=cart.items|length %} You have {{ counter }} item in your cart. {% plural %} You have {{ counter }} items in your cart. {% endblocktranslate %} ``` | Piece | Meaning | |---|---| | `count counter=expr` | binds the number; the value must be an `int`, `float` or `Decimal`, otherwise `TemplateSyntaxError` | | `{% plural %}` | separates the singular and plural msgids | | `with total=…` | binds extra values; the same "same names in both forms" rule applies | | `context "…"` | adds a context marker, turning the call into `npgettext()` | | `trimmed` | strips leading and trailing newlines, trims each line and joins the lines with single spaces | Internally the tag builds `%(counter)s`-style msgids and calls `ngettext()`, or `npgettext()` with a context. That is why the Python rules apply unchanged. ## Why `trimmed` matters Without `trimmed`, the indentation and newlines inside the block become part of the msgid, for example `"\n You have %(counter)s item…\n"`. Translators see noise, and re-indenting the template changes the msgid and silently breaks the existing translation. With `trimmed`, the msgid is the clean sentence `"You have %(counter)s item in your cart."`, so the template can be reformatted freely. ## Testing plural forms Plural bugs rarely show up in the source language, so test the translated forms directly: - render the message with the target language active for counts **0, 1, 2 and 5** (for Polish also 12 and 22, which fall into different forms); - check that every plural form in each `.po` file is filled in, since a partly translated plural entry is a common cause of counts that show the wrong text; - for template blocks, assert on the rendered text rather than on the msgid, since `trimmed` and placeholder names change the msgid. ## JavaScript The JavaScript catalog view exposes an `ngettext()` function with the same arguments. It uses the language's plural rule, and `interpolate()` fills the placeholders in afterwards.

  • What goes wrong if the singular msgid uses %(name)s but the plural uses %(plural_name)s?
    Every form in the catalog must use placeholders that exist in the msgid. `compilemessages` then fails with an error that a format specification for an argument doesn't exist in the msgid. Beyond that, choosing `verbose_name` versus `verbose_name_plural` yourself reapplies the English rule. Put the noun into both msgids and use a single set of placeholder names.
  • Why can't you just pass the count to gettext() and handle the noun with an if?
    Because the branching rule belongs to the target language, not to your code. French uses the singular for 0, and Polish has four forms that depend on the last digits. Only `ngettext()` hands the count to the catalog's `Plural-Forms` expression, so the translator can supply every form the language needs.

saying these in an interview costs you the question

  • if count == 1 is a correct way to pick singular or plural in every language.
  • ngettext() fills in the count placeholder by itself.
  • Singular and plural msgids may use different placeholder names.
  • trimmed only changes how the rendered HTML looks, not the msgid.
  • ngettext_lazy() needs the number when the class is defined.